This commit is contained in:
vcoppe
2026-10-04 16:11:44 +02:00
parent ec448b72e2
commit 7651d859c4
9 changed files with 325 additions and 341 deletions
+5 -3
View File
@@ -1,8 +1,8 @@
use crate::{Apply, CommandError, State}; use crate::{Apply, CommandError, State};
use crate::{ use crate::{
Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata, Move, Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, LoadFiles, Merge,
MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, SpliceTrackpoints, Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse,
Split, Style, Time, SpliceTrackpoints, Split, Style, Time,
}; };
/// A user action that edits the files. Each variant wraps the command's own struct, whose /// A user action that edits the files. Each variant wraps the command's own struct, whose
@@ -14,6 +14,7 @@ use crate::{
pub enum Command<'a> { pub enum Command<'a> {
New(New<'a>), New(New<'a>),
Load(Load<'a>), Load(Load<'a>),
LoadFiles(LoadFiles<'a>),
Duplicate(Duplicate), Duplicate(Duplicate),
Delete(Delete), Delete(Delete),
Paste(Paste), Paste(Paste),
@@ -41,6 +42,7 @@ impl Apply for Command<'_> {
match self { match self {
Command::New(c) => c.apply(state), Command::New(c) => c.apply(state),
Command::Load(c) => c.apply(state), Command::Load(c) => c.apply(state),
Command::LoadFiles(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state), Command::Duplicate(c) => c.apply(state),
Command::Delete(c) => c.apply(state), Command::Delete(c) => c.apply(state),
Command::Paste(c) => c.apply(state), Command::Paste(c) => c.apply(state),
+136 -4
View File
@@ -1,4 +1,4 @@
use crate::{Apply, CommandError, State, parse, produce}; use crate::{Apply, CommandError, File, State, parse, produce};
#[derive(Debug)] #[derive(Debug)]
pub struct Load<'a> { pub struct Load<'a> {
@@ -8,14 +8,49 @@ pub struct Load<'a> {
pub name: &'a str, pub name: &'a str,
} }
impl Apply for Load<'_> { impl Load<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> { fn parse(self) -> Result<File, CommandError> {
let mut file = let mut file =
parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?; parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?;
if file.info.name.trim().is_empty() { if file.info.name.trim().is_empty() {
file.info.name = self.name.to_owned(); file.info.name = self.name.to_owned();
} }
produce(state, |_| vec![file]); Ok(file)
}
}
impl Apply for Load<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
LoadFiles { files: vec![self] }.apply(state)
}
}
/// Loads several files at once, as a single command (one undo step): the files are added at the
/// end of the list, in the given order, and the first one is selected.
///
/// The files that cannot be read are skipped, unless none of them can: then nothing is loaded
/// and the error is the one of the first file.
#[derive(Debug)]
pub struct LoadFiles<'a> {
pub files: Vec<Load<'a>>,
}
impl Apply for LoadFiles<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let mut files = Vec::with_capacity(self.files.len());
let mut first_error = None;
for load in self.files {
match load.parse() {
Ok(file) => files.push(file),
Err(error) => {
first_error.get_or_insert(error);
}
}
}
if files.is_empty() {
return Err(first_error.unwrap_or(CommandError::NothingToDo));
}
produce(state, |_| files);
Ok(()) Ok(())
} }
} }
@@ -85,4 +120,101 @@ mod tests {
let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#; let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#;
assert_eq!(load_with_name(empty), "from disk"); assert_eq!(load_with_name(empty), "from disk");
} }
fn gpx(name: &str) -> Vec<u8> {
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
}
fn load<'a>(data: &'a [u8], name: &'a str) -> Load<'a> {
Load { data, name }
}
#[test]
fn test_load_several_files() {
let mut fx = Fixture::default();
crate::New { name: "before" }
.apply(&mut fx.state())
.unwrap();
let before = fx.order.0[0];
let (a, b, c) = (gpx("a"), gpx("b"), gpx("c"));
LoadFiles {
files: vec![load(&a, "x"), load(&b, "x"), load(&c, "x")],
}
.apply(&mut fx.state())
.unwrap();
// added after the others, in the order they were given
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["before", "a", "b", "c"]);
assert_eq!(fx.order.0[0], before);
// the first one is selected
assert_eq!(fx.selected_files(), [fx.order.0[1]].into());
}
#[test]
fn test_load_several_files_skips_the_invalid_ones() {
let mut fx = Fixture::default();
let (a, c) = (gpx("a"), gpx("c"));
LoadFiles {
files: vec![load(&a, "x"), load(b"<gpx><trk></gpx>", "x"), load(&c, "x")],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["a", "c"]);
}
#[test]
fn test_load_several_invalid_files_changes_nothing() {
let mut fx = Fixture::default();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let selected = fx.selected_files();
let result = LoadFiles {
files: vec![
load(b"<gpx><trk></gpx>", "x"),
load(b"<gpx><wpt></gpx>", "y"),
],
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_))));
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.order.0.len(), 1);
assert_eq!(fx.selected_files(), selected);
// nothing to load
assert_eq!(
LoadFiles { files: vec![] }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_each_file_without_name_gets_its_own_name() {
let mut fx = Fixture::default();
let unnamed = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
LoadFiles {
files: vec![load(unnamed, "first"), load(unnamed, "second")],
}
.apply(&mut fx.state())
.unwrap();
let names: Vec<_> = fx
.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect();
assert_eq!(names, ["first", "second"]);
}
} }
+39
View File
@@ -1071,4 +1071,43 @@ mod tests {
assert!(engine.execute(Action::Undo)); assert!(engine.execute(Action::Undo));
assert_eq!(track_ids(&engine, a), tracks); assert_eq!(track_ids(&engine, a), tracks);
} }
#[test]
fn test_load_several_files_is_one_edit() {
use crate::LoadFiles;
let mut engine = Engine::default();
new(&mut engine, "before");
let gpx = |name: &str| {
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#)
.into_bytes()
};
let (a, b) = (gpx("a"), gpx("b"));
assert!(edit(
&mut engine,
Command::LoadFiles(LoadFiles {
files: vec![
Load {
data: &a,
name: "x"
},
Load {
data: &b,
name: "x"
},
],
})
));
assert_eq!(engine.order().len(), 3);
let diff = engine.last_diff().unwrap();
assert_eq!(diff.added.len(), 2);
// the first one that was loaded is selected
assert_eq!(engine.selection(), &files(&[engine.order()[1]]));
// a single undo removes both
assert!(engine.execute(Action::Undo));
assert_eq!(engine.order().len(), 1);
assert!(engine.execute(Action::Redo));
assert_eq!(engine.order().len(), 3);
}
} }
+19 -3
View File
@@ -171,10 +171,26 @@ pub fn new_file(name: &str) -> bool {
edit(Command::New(engine::New { name })) edit(Command::New(engine::New { name }))
} }
/// `name`: the name of the file when the data has none (the name on disk, without extension). /// Loads files as a single command (one undo step). The files are in `data`, one after the
/// other, `lengths` being the number of bytes of each one. `names`: the name of each file when
/// its data has none (the name on disk, without extension). The files that cannot be read are
/// skipped, the first one that was read is selected.
#[wasm_bindgen] #[wasm_bindgen]
pub fn load_file(data: &[u8], name: &str) -> bool { pub fn load_files(data: &[u8], lengths: &[u32], names: Vec<String>) -> bool {
edit(Command::Load(engine::Load { data, name })) if lengths.len() != names.len() {
return false;
}
let mut files = Vec::with_capacity(names.len());
let mut offset = 0;
for (length, name) in lengths.iter().zip(&names) {
let end = offset + *length as usize;
let Some(data) = data.get(offset..end) else {
return false;
};
files.push(engine::Load { data, name });
offset = end;
}
edit(Command::LoadFiles(engine::LoadFiles { files }))
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -7,7 +7,6 @@
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte'; import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte'; import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import { import {
allowedEmbeddingBasemaps, allowedEmbeddingBasemaps,
getFilesFromEmbeddingOptions, getFilesFromEmbeddingOptions,
@@ -15,9 +14,7 @@
} from './embedding'; } from './embedding';
import { setMode } from 'mode-watcher'; import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state'; import { loadFiles } from '$lib/logic/file-actions';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
@@ -30,6 +27,8 @@
hash = $bindable(), hash = $bindable(),
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props(); }: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
const { files } = engine;
let additionalDatasets = writable<string[]>([]); let additionalDatasets = writable<string[]>([]);
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined); let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
@@ -74,26 +73,14 @@
); );
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill); elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map( let downloads: Promise<File>[] = getFilesFromEmbeddingOptions(options).map((url) => {
(url) => {
return fetch(url) return fetch(url)
.then((response) => response.blob()) .then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? url)) .then((blob) => new File([blob], url.split('/').pop() ?? url));
.then(loadFile);
}
);
Promise.all(downloads).then((answers) => {
const files = answers.filter((file) => file !== null) as GPXFile[];
let ids: string[] = [];
files.forEach((file, index) => {
let id = `gpx-${index}-embed`;
file._data.id = id;
ids.push(id);
});
fileStateCollection.setEmbeddedFiles(files);
$fileOrder = ids;
selection.selectAll();
}); });
Promise.all(downloads)
.then(loadFiles)
.then(() => engine.selectAll());
} }
$effect(() => { $effect(() => {
@@ -105,7 +92,7 @@
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip"> <div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
<div class="grow relative"> <div class="grow relative">
<Map <Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}" class="h-full {$files.size > 1 ? 'horizontal' : ''}"
maptilerKey={options.key} maptilerKey={options.key}
geocoder={false} geocoder={false}
geolocate={true} geolocate={true}
@@ -114,7 +101,7 @@
<OpenIn files={options.files} ids={options.ids} /> <OpenIn files={options.files} ids={options.ids} />
<LayerControl /> <LayerControl />
<GPXLayers /> <GPXLayers />
{#if $fileStateCollection.size > 1} {#if $files.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30"> <div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
<FileList orientation="horizontal" /> <FileList orientation="horizontal" />
</div> </div>
+43
View File
@@ -0,0 +1,43 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { defaultFileName } from './default-file-name';
describe('default file name', () => {
it('is the default name when there is no other file with it', () => {
assert.equal(defaultFileName('New file', []), 'New file');
assert.equal(defaultFileName('New file', ['Morning ride', 'track']), 'New file');
});
it('is numbered when other files have the default name', () => {
assert.equal(defaultFileName('New file', ['New file']), 'New file 2');
assert.equal(defaultFileName('New file', ['New file', 'New file 2']), 'New file 3');
});
it('goes after the highest number', () => {
assert.equal(defaultFileName('New file', ['New file 4', 'New file']), 'New file 5');
// a number that was freed is not used again
assert.equal(defaultFileName('New file', ['New file', 'New file 3']), 'New file 4');
// the numbered ones count even if the first one was renamed
assert.equal(defaultFileName('New file', ['New file 2']), 'New file 3');
});
it('ignores the names that only look like it', () => {
assert.equal(defaultFileName('New file', ['New file copy', 'New files 3']), 'New file');
assert.equal(
defaultFileName('New file', ['New file 2b', 'New file 0', 'New file -3']),
'New file'
);
assert.equal(defaultFileName('New file', ['New file ', 'New file 2']), 'New file');
assert.equal(defaultFileName('New file', ['my New file']), 'New file');
});
it('works with any language', () => {
assert.equal(defaultFileName('Nouveau fichier', ['Nouveau fichier']), 'Nouveau fichier 2');
assert.equal(defaultFileName('新文件', ['新文件', '新文件 2']), '新文件 3');
// the default name of another language is just a name
assert.equal(
defaultFileName('Nouveau fichier', ['New file', 'New file 2']),
'Nouveau fichier'
);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* The name of a new file: the default name (translated), followed by a number when other files
* already have a default name (the name alone counts as number 1): "New file", "New file 2",
* "New file 3"...
*/
export function defaultFileName(defaultName: string, existingNames: string[]): string {
let highest = 0;
for (const name of existingNames) {
if (name === defaultName) {
highest = Math.max(highest, 1);
} else if (name.startsWith(`${defaultName} `)) {
const suffix = name.slice(defaultName.length + 1);
if (/^[1-9][0-9]*$/.test(suffix)) {
highest = Math.max(highest, parseInt(suffix));
}
}
}
return highest === 0 ? defaultName : `${defaultName} ${highest + 1}`;
}
+20 -3
View File
@@ -242,9 +242,26 @@ class Engine {
return this.run((w) => w.new_file(name)); return this.run((w) => w.new_file(name));
} }
/** `name` is the name of the file when the data has none (the name on disk, without extension). */ /**
loadFile(data: Uint8Array, name: string) { * Loads files as a single command (one undo step). `name` is the name of a file when its data
return this.run((w) => w.load_file(data, name)); * has none (the name on disk, without extension). The files that cannot be read are skipped,
* the first one that was read is selected.
*/
loadFiles(files: { data: Uint8Array; name: string }[]) {
const lengths = Uint32Array.from(files.map((file) => file.data.length));
const data = new Uint8Array(lengths.reduce((sum, length) => sum + length, 0));
let offset = 0;
for (const file of files) {
data.set(file.data, offset);
offset += file.data.length;
}
return this.run((w) =>
w.load_files(
data,
lengths,
files.map((file) => file.name)
)
);
} }
duplicate() { duplicate() {
+30 -301
View File
@@ -10,15 +10,12 @@ import {
ListTrackItem, ListTrackItem,
ListTrackSegmentItem, ListTrackSegmentItem,
ListWaypointItem, ListWaypointItem,
ListWaypointsItem,
sortItems,
type ListItem, type ListItem,
} from '$lib/components/file-list/file-list'; } from '$lib/components/file-list/file-list';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { freeze, type WritableDraft } from 'immer'; import { freeze } from 'immer';
import { import {
GPXFile, GPXFile,
parseGPX,
Track, Track,
TrackPoint, TrackPoint,
TrackSegment, TrackSegment,
@@ -32,6 +29,8 @@ import { settings } from '$lib/logic/settings';
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils'; import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils';
import { gpxStatistics } from '$lib/logic/statistics'; import { gpxStatistics } from '$lib/logic/statistics';
import { boundsManager } from './bounds'; import { boundsManager } from './bounds';
import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/default-file-name';
// Generate unique file ids, different from the ones in the database // Generate unique file ids, different from the ones in the database
export function getFileIds(n: number) { export function getFileIds(n: number) {
@@ -45,31 +44,21 @@ export function getFileIds(n: number) {
return ids; return ids;
} }
/** The name of a new file: the translated default name, numbered if other files have it. */
export function newFileName() {
const names = [...get(engine.files).values()].map((file) => get(file).structure.name);
return defaultFileName(i18n._('menu.new_file'), names);
}
export function newGPXFile() { export function newGPXFile() {
const newFileName = i18n._('menu.new_file');
let file = new GPXFile(); let file = new GPXFile();
file.metadata.name = newFileName();
let maxNewFileNumber = 0;
fileStateCollection.forEach((fileId, file) => {
if (file.metadata.name && file.metadata.name.startsWith(newFileName)) {
let number = parseInt(file.metadata.name.split(' ').pop() ?? '0');
if (!isNaN(number) && number > maxNewFileNumber) {
maxNewFileNumber = number;
}
}
});
file.metadata.name = `${newFileName} ${maxNewFileNumber + 1}`;
return file; return file;
} }
export function createFile() { export function createFile() {
let file = newGPXFile(); // the engine selects the new file
engine.newFile(newFileName());
fileActions.add(file);
selection.selectFileWhenLoaded(file._data.id);
currentTool.set(Tool.ROUTING); currentTool.set(Tool.ROUTING);
} }
@@ -87,41 +76,27 @@ export function triggerFileInput() {
input.click(); input.click();
} }
export async function loadFiles(list: FileList | File[]) { /**
let files: GPXFile[] = []; * Loads GPX files in the engine, as a single command: one undo step, the first file that could be
for (let i = 0; i < list.length; i++) { * read is selected, and the map fits the files. A file without name is named like the file on
let file = await loadFile(list[i]); * disk. Returns the ids of the files that were added.
if (file) { */
files.push(file); export async function loadFiles(list: FileList | File[]): Promise<string[]> {
} const files = await Promise.all(
} Array.from(list).map(async (file) => ({
data: new Uint8Array(await file.arrayBuffer()),
name: file.name.split('.').slice(0, -1).join('.'),
}))
);
let ids = fileActions.addMultiple(files); const before = new Set(get(engine.order));
selection.selectFileWhenLoaded(ids[0]); await engine.loadFiles(files);
const ids = get(engine.order).filter((id) => !before.has(id));
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids); boundsManager.fitBoundsOnLoad(ids);
}
export async function loadFile(file: File): Promise<GPXFile | null> {
let result = await new Promise<GPXFile | null>((resolve) => {
const reader = new FileReader();
reader.onload = () => {
let data = reader.result?.toString() ?? null;
if (data) {
let gpx = parseGPX(data);
if (gpx.metadata === undefined) {
gpx.metadata = {};
} }
if (gpx.metadata.name === undefined || gpx.metadata.name.trim() === '') { return ids;
gpx.metadata.name = file.name.split('.').slice(0, -1).join('.');
}
resolve(gpx);
} else {
resolve(null);
}
};
reader.readAsText(file);
});
return result;
} }
// Helper functions for file operations // Helper functions for file operations
@@ -891,249 +866,3 @@ export const fileActions = {
}); });
}, },
}; };
export function pasteSelection() {
let fromItems = get(copied);
if (fromItems === undefined || fromItems.length === 0) {
return;
}
let selected = get(selection).getSelected();
if (selected.length === 0) {
selected = [new ListRootItem()];
}
let fromParent = fromItems[0].getParent();
let toParent = selected[selected.length - 1];
let startIndex: number | undefined = undefined;
if (fromItems[0].level === toParent.level) {
if (
toParent instanceof ListTrackItem ||
toParent instanceof ListTrackSegmentItem ||
toParent instanceof ListWaypointItem
) {
startIndex = toParent.getId() + 1;
}
toParent = toParent.getParent();
}
let toItems: ListItem[] = [];
if (toParent.level === ListLevel.ROOT) {
let fileIds = getFileIds(fromItems.length);
fileIds.forEach((fileId) => {
toItems.push(new ListFileItem(fileId));
});
} else {
let toFile = fileStateCollection.getFile(toParent.getFileId());
if (toFile) {
fromItems.forEach((item, index) => {
if (toParent instanceof ListFileItem) {
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
toItems.push(
new ListTrackItem(
toParent.getFileId(),
(startIndex ?? toFile.trk.length) + index
)
);
} else if (item instanceof ListWaypointsItem) {
toItems.push(new ListWaypointsItem(toParent.getFileId()));
} else if (item instanceof ListWaypointItem) {
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
} else if (toParent instanceof ListTrackItem) {
if (item instanceof ListTrackSegmentItem) {
let toTrackIndex = toParent.getTrackIndex();
toItems.push(
new ListTrackSegmentItem(
toParent.getFileId(),
toTrackIndex,
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
)
);
}
} else if (toParent instanceof ListWaypointsItem) {
if (item instanceof ListWaypointItem) {
toItems.push(
new ListWaypointItem(
toParent.getFileId(),
(startIndex ?? toFile.wpt.length) + index
)
);
}
}
});
}
}
if (fromItems.length === toItems.length) {
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
selection.resetCopied();
}
}
export function moveItems(
fromParent: ListItem,
toParent: ListItem,
fromItems: ListItem[],
toItems: ListItem[],
remove: boolean = true
) {
if (fromItems.length === 0) {
return;
}
sortItems(fromItems, false);
sortItems(toItems, false);
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
fromItems.forEach((item) => {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
if (item instanceof ListFileItem) {
context.push(file.clone());
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
context.push(file.trk[item.getTrackIndex()].clone());
} else if (
item instanceof ListTrackSegmentItem &&
item.getTrackIndex() < file.trk.length &&
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
) {
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
} else if (item instanceof ListWaypointsItem) {
context.push(file.wpt.map((wpt) => wpt.clone()));
} else if (
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
context.push(file.wpt[item.getWaypointIndex()].clone());
}
}
});
if (remove && !(fromParent instanceof ListRootItem)) {
sortItems(fromItems, true);
}
let files = [fromParent.getFileId(), toParent.getFileId()];
let callbacks = [
(
file: WritableDraft<GPXFile>,
context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]
) => {
fromItems.forEach((item) => {
if (item instanceof ListTrackItem) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
} else if (item instanceof ListTrackSegmentItem) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex(),
[]
);
} else if (item instanceof ListWaypointsItem) {
file.replaceWaypoints(0, file.wpt.length - 1, []);
} else if (item instanceof ListWaypointItem) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
}
});
},
(
file: WritableDraft<GPXFile>,
context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]
) => {
toItems.forEach((item, i) => {
if (item instanceof ListTrackItem) {
if (context[i] instanceof Track) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
context[i],
]);
} else if (context[i] instanceof TrackSegment) {
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
new Track({
trkseg: [context[i]],
}),
]);
}
} else if (
item instanceof ListTrackSegmentItem &&
context[i] instanceof TrackSegment
) {
file.replaceTrackSegments(
item.getTrackIndex(),
item.getSegmentIndex(),
item.getSegmentIndex() - 1,
[context[i]]
);
} else if (item instanceof ListWaypointsItem) {
if (
Array.isArray(context[i]) &&
context[i].length > 0 &&
context[i][0] instanceof Waypoint
) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
} else if (context[i] instanceof Waypoint) {
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
}
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
context[i],
]);
}
});
},
];
if (fromParent instanceof ListRootItem) {
files = [];
callbacks = [];
} else if (!remove) {
files.splice(0, 1);
callbacks.splice(0, 1);
}
fileActionManager.applyEachToFilesAndGlobal(
files,
callbacks,
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
toItems.forEach((item, i) => {
if (item instanceof ListFileItem) {
if (context[i] instanceof GPXFile) {
let newFile = context[i];
if (remove) {
files.delete(newFile._data.id);
}
newFile._data.id = item.getFileId();
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof Track) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
if (context[i].name) {
newFile.metadata.name = context[i].name;
}
newFile.replaceTracks(0, 0, [context[i]]);
files.set(item.getFileId(), freeze(newFile));
} else if (context[i] instanceof TrackSegment) {
let newFile = newGPXFile();
newFile._data.id = item.getFileId();
newFile.replaceTracks(0, 0, [
new Track({
trkseg: [context[i]],
}),
]);
files.set(item.getFileId(), freeze(newFile));
}
}
});
},
context
);
selection.set(toItems);
}