SOURCE / PINNED RELEASE
Made of little things.
Black Site Map Editor
- Release
- 32a57733b7e9…
- Author-recorded commit
- 3f54890f542f…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor
Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { followedAuthors } from './map-social';
import type { NostrEvent } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { readDeploymentControls, readObjectControls } from './editor-controls';
import { friendlyError } from './friendly-error';
import {
MapHistory,
createMap,
cloneMap,
place,
hitTest,
selectionValue,
moveSelection,
deleteSelection,
resizeMap,
validateMap,
validatePlayable,
type BlackSiteMap,
type Layer,
type Point,
type Selection,
} from './model';
import {
discoverMaps,
loadMap,
publishMap,
deleteMap,
saveDraft,
loadDraft,
resolveMap,
saveTutorialSeen,
type MapRecord,
} from './map-host';
import type { MapIntent } from './map-intent';
import type { Shortcuts } from './shortcuts';
import { store, type EditorState, type WorkspaceTab } from './state/editor-state';
import { toolLabel } from './tools';
/** View hooks the controller drives imperatively; components register themselves. */
export interface CanvasView {
fit(): void;
zoom(factor: number): void;
draw(): void;
}
export interface ConfirmView {
ask(message: string, title: string): Promise<boolean>;
readonly isOpen: boolean;
}
export interface DialogView {
show(stepId?: string): void;
close(): void;
readonly isOpen: boolean;
}
type MapContext = {
base?: MapRecord;
remixOf?: NostrEvent;
remixRelay?: string;
content?: string;
hashtags?: string[];
image?: string;
};
export type MetadataFields = {
id: string;
name: string;
desc: string;
style: BlackSiteMap['theme']['style'];
ground: string;
road: string;
markings: boolean;
};
const hexRgb = (value: string): [number, number, number] =>
[1, 3, 5].map((i) => parseInt(value.slice(i, i + 2), 16)) as [number, number, number];
const freshId = (id: string) => `${id.slice(0, 19)}-${Date.now().toString(36)}`;
export class EditorController {
readonly ui: {
canvas?: CanvasView;
confirm?: ConfirmView;
publication?: DialogView;
library?: DialogView;
help?: DialogView;
settings?: DialogView;
shortcuts?: DialogView;
} = {};
shortcuts: Shortcuts = { states: () => [], get: () => undefined, close() {} };
#history = new MapHistory(store.state.map);
#identityEpoch = 0;
#requestEpoch = 0;
#workspaceVersion = 0;
#publishReceipt: unknown;
#libraryAuthor: string | undefined;
#following = false;
#nextUntil: number | undefined;
#pendingIntent: MapIntent | undefined;
#intentInProgress = false;
#intentVersion = 0;
get state(): EditorState {
return store.state;
}
get map(): BlackSiteMap {
return this.#history.current;
}
get workspaceVersion(): number {
return this.#workspaceVersion;
}
get intentPending(): boolean {
return Boolean(this.#pendingIntent) || this.#intentInProgress;
}
get anyDialogOpen(): boolean {
return [...document.querySelectorAll('dialog')].some((dialog) => dialog.open);
}
// ---- state plumbing ----
/** Publish the history and an optional patch; drops a selection whose object no longer exists. */
#sync(patch: Partial<EditorState> = {}) {
const map = this.#history.current;
let selection = 'selection' in patch ? patch.selection : store.state.selection;
if (selection && !selectionValue(map, selection)) selection = undefined;
store.set({
...patch,
map,
canUndo: this.#history.canUndo,
canRedo: this.#history.canRedo,
selection,
});
}
status(message: string, kind = '') {
store.set({ status: { message, kind } });
}
fail = (error: unknown) => this.status(friendlyError(error), 'error');
/** Wrap a handler so thrown or rejected errors become a status message. */
guard = (fn: () => unknown) => () => {
try {
const result = fn();
if (result instanceof Promise) result.catch(this.fail);
} catch (error) {
this.fail(error);
}
};
bumpShortcuts() {
store.set({ shortcutsRev: store.state.shortcutsRev + 1 });
}
// ---- workspace ----
confirmAction(message: string, title = 'Confirm'): Promise<boolean> {
return this.ui.confirm ? this.ui.confirm.ask(message, title) : Promise.resolve(false);
}
async replaceAllowed() {
return (
!store.state.dirty ||
(await this.confirmAction(
'Replace your unsaved map? Save a draft or download a backup first to keep it.',
'Replace map',
))
);
}
setMap(value: BlackSiteMap, context: MapContext = {}, dirty = false) {
if (store.state.busy) throw new Error('Wait for the current publication to finish.');
this.#workspaceVersion++;
this.#requestEpoch++;
this.#history = new MapHistory(value);
this.#publishReceipt = undefined;
this.#sync({
selection: undefined,
base: context.base,
remixOf: context.remixOf,
remixRelay: context.remixRelay,
content: context.content ?? '',
hashtags: context.hashtags ?? [],
image: context.image,
dirty,
publishDetails: '',
});
this.ui.canvas?.fit();
}
commit(value: BlackSiteMap, patch: Partial<EditorState> = {}) {
const errors = validateMap(value);
if (errors.length) throw new Error(errors.join('\n'));
this.#history.commit(value);
this.#workspaceVersion++;
this.#sync({ ...patch, dirty: true });
}
undo() {
if (store.state.busy) return;
this.#history.undo();
this.#workspaceVersion++;
this.#sync({ dirty: true, selection: undefined });
}
redo() {
if (store.state.busy) return;
this.#history.redo();
this.#workspaceVersion++;
this.#sync({ dirty: true, selection: undefined });
}
updateMetadata(fields: MetadataFields) {
const m = cloneMap(this.map);
m.id = fields.id;
m.name = fields.name;
m.desc = fields.desc;
m.theme.style = fields.style;
m.theme.ground = hexRgb(fields.ground);
m.theme.road = hexRgb(fields.road);
m.theme.markings = fields.markings;
this.commit(m);
}
resize(width: number, height: number) {
this.commit(resizeMap(this.map, width, height));
this.ui.canvas?.fit();
}
setContent(content: string) {
this.#workspaceVersion++;
store.set({ content, dirty: true });
}
setHashtags(value: string) {
const hashtags = value
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
this.#workspaceVersion++;
store.set({ hashtags, dirty: true });
}
applyDeployment(container: HTMLElement) {
this.commit(readDeploymentControls(container, this.map));
this.status('Extra scenery and enemies updated.');
}
applyJson(source: string) {
const value = JSON.parse(source);
const errors = validateMap(value);
if (errors.length) throw new Error(errors.join('\n'));
const base = store.state.base;
if (base && value.id !== base.metadata.id)
throw new Error(
'A published map keeps its ID. Use New or Remix to publish a separate map.',
);
this.commit(value, { selection: undefined });
this.ui.canvas?.fit();
this.status('Map JSON applied.');
}
validate() {
const errors = validatePlayable(this.map);
store.set({
validation: errors.length
? errors.map(friendlyError).join('\n')
: 'Ready to play · insertion can reach extraction.',
});
this.status(
errors.length ? 'Map needs attention. See Map Check.' : 'Your map is ready to play.',
errors.length ? 'error' : 'success',
);
}
// ---- tools, selection and canvas edits ----
setTool(tool: string) {
store.set({ tool });
this.status(`${toolLabel(tool)} tool ready.`);
}
setPlaceOptions(patch: Partial<EditorState['placeOptions']>) {
store.set({ placeOptions: { ...store.state.placeOptions, ...patch } });
}
toggleGrid() {
store.set({ showGrid: !store.state.showGrid });
}
setWorkspaceTab(workspaceTab: WorkspaceTab) {
store.set({ workspaceTab });
}
select(selection: Selection | undefined) {
store.set({ selection });
}
selectObject(selection: Selection) {
this.setTool('select');
this.select(selection);
}
selectAt(p: Point): Selection | undefined {
const selection = hitTest(this.map, ...p);
this.select(selection);
return selection;
}
moveSelectionBy(dx: number, dy: number, selection = store.state.selection) {
if (selection) this.commit(moveSelection(this.map, selection, dx, dy));
}
nudge(dx: number, dy: number) {
if (!store.state.selection) {
this.status('Select an object first, then use the arrow keys to move it.');
return;
}
this.moveSelectionBy(dx, dy);
}
eraseAt(p: Point) {
const s = hitTest(this.map, ...p);
if (s && !['start', 'exfil'].includes(s.layer))
this.commit(deleteSelection(this.map, s), { selection: undefined });
}
placeRect(mode: string, from: Point, to: Point) {
const { selection, placeOptions } = store.state;
const result = place(this.map, mode as Layer, from, to, {
color: placeOptions.color,
patrol: placeOptions.patrol,
cacheKind: placeOptions.cacheKind || undefined,
buildingIndex:
selection?.layer === 'buildings' ? selection.index : selection?.buildingIndex,
});
this.commit(result.map, { selection: result.selection });
this.status(`Placed ${toolLabel(mode).toLowerCase()}.`);
}
applyObject(container: HTMLElement) {
const selection = store.state.selection;
if (!selection) return;
this.commit(readObjectControls(container, this.map, selection));
this.status('Object updated.');
}
deleteSelected() {
const selection = store.state.selection;
if (selection && !['start', 'exfil'].includes(selection.layer))
this.commit(deleteSelection(this.map, selection), { selection: undefined });
}
// ---- files ----
download(value: string, filename: string) {
const blob = new Blob([value], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
store.set({
exportFallback: 'If the download does not appear, use Save draft to keep a backup.',
});
}
exportJson() {
this.download(JSON.stringify(this.map, null, 2), `${this.map.id}.json`);
}
exportEvent() {
const { base, remixOf, remixRelay, content, hashtags } = store.state;
this.download(
JSON.stringify(
this.#publishReceipt ?? {
map: this.map,
base: base?.event,
remixOf,
remixRelay,
content,
hashtags,
},
null,
2,
),
`${this.map.id}-publication.json`,
);
}
async importFile(file: File) {
if (file.size > 262144)
throw new Error('This map file is too large. Choose a smaller map file.');
const value = JSON.parse(await file.text());
const errors = validateMap(value);
if (errors.length) throw new Error(errors.join('\n'));
if (await this.replaceAllowed()) {
this.setMap(value, {}, true);
this.status('Imported a local copy. Publishing creates a map under your identity.');
}
}
async newMap() {
if (store.state.busy || !(await this.replaceAllowed())) return;
this.setMap(createMap(`map-${Date.now().toString(36)}`));
this.status('New map. Place insertion, extraction, terrain and units.');
}
async remixCurrent() {
const { busy, viewer, base } = store.state;
if (busy || !viewer || !base) return;
const epoch = this.#identityEpoch;
if (
!(await this.confirmAction(
'Create a new map from this one and keep the original credits?',
'Remix current map',
))
)
return;
if (store.state.busy || epoch !== this.#identityEpoch) return;
const value = cloneMap(this.map);
value.id = freshId(value.id);
const { content, hashtags, image } = store.state;
this.setMap(
value,
{
remixOf: base.event,
remixRelay: base.relayHints[0],
content,
hashtags,
image,
},
true,
);
this.status(
'Remix ready under your current identity. Original and parent credits retained.',
);
}
// ---- library ----
#library(patch: Partial<EditorState['library']>) {
store.set({ library: { ...store.state.library, ...patch } });
}
async openLibrary() {
if (store.state.busy) return;
this.ui.library?.show();
if (store.state.viewer) await this.browse(store.state.viewer);
else this.showTemplates();
}
showTemplates() {
if (store.state.busy) return;
this.#requestEpoch++;
this.#library({
view: 'templates',
loadMore: false,
status: 'Built-in Black Site maps · exact map tiles, procedural generation removed. Use as a local starting point.',
});
}
async useTemplate(template: BlackSiteMap) {
if (store.state.busy || !(await this.replaceAllowed())) return;
const m = cloneMap(template);
m.id = freshId(m.id);
this.setMap(m, { content: `Based on Black Site built-in ${template.name}.` }, true);
this.ui.library?.close();
this.status('Template ready. Author attribution is included in publication notes.');
}
searchAuthor(value: string) {
const author = value.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(author))
throw new Error('Enter a 64-character hex Nostr public key.');
return this.browse(author);
}
loadMore() {
return this.browse(this.#libraryAuthor, true, this.#following);
}
async browse(author?: string, append = false, following = false) {
if (author === '') {
this.#library({ status: 'Connect your Nostr identity in the host to load your maps.' });
return;
}
if (!runtimeHasDomain('outbox'))
throw new Error('Map browsing is unavailable here. Try another host.');
const epoch = ++this.#requestEpoch,
accountEpoch = this.#identityEpoch;
this.#libraryAuthor = author;
this.#following = following;
this.#library({
status: 'Finding maps…',
...(append ? {} : { view: 'records', records: [], loadMore: false }),
});
try {
let authors: string[] | undefined;
if (following) {
if (!store.state.viewer)
throw new Error('Sign in to browse maps from people you follow.');
authors = await followedAuthors(store.state.viewer);
if (epoch !== this.#requestEpoch || accountEpoch !== this.#identityEpoch) return;
if (!authors.length) {
this.#library({
view: 'records',
records: [],
loadMore: false,
status: 'You are not following anyone yet. Follow creators in your Nostr client, or explore Global maps.',
});
return;
}
}
const result = await discoverMaps({
author,
authors,
until: append ? this.#nextUntil : undefined,
});
if (epoch !== this.#requestEpoch || accountEpoch !== this.#identityEpoch) return;
const previous = store.state.library.records;
const records = append
? [
...previous,
...result.maps.filter(
(m) => !previous.some((r) => r.metadata.address === m.metadata.address),
),
]
: result.maps;
this.#nextUntil = result.nextUntil;
this.#library({
view: 'records',
records,
loadMore: this.#nextUntil !== undefined && !result.paginationBlocked,
status: `${records.length} maps${result.invalid ? ' · some maps could not be opened' : ''}${result.paginationBlocked ? ' · more maps may be available; try again later' : ''}${result.incomplete ? ' · some results are missing; try again' : ''}${result.error ? ' · maps could not be loaded; try again' : ''}${!records.length && !result.incomplete && !result.error ? ' · no published maps found' : ''}`,
});
} catch (error) {
if (epoch !== this.#requestEpoch || accountEpoch !== this.#identityEpoch) return;
this.#library({
view: 'records',
records: [],
loadMore: false,
status:
error instanceof Error
? error.message
: 'Maps could not be loaded. Please retry.',
});
}
}
async deleteRecord(record: MapRecord) {
const m = record.metadata;
if (
!(await this.confirmAction(
`Remove ${m.title} from your published maps? Copies shared by others may remain.`,
`Delete published map`,
))
)
return;
const epoch = this.#identityEpoch;
await deleteMap(record);
if (epoch !== this.#identityEpoch) return;
this.#library({
records: store.state.library.records.filter((r) => r.metadata.address !== m.address),
});
this.status('Deletion request published.', 'success');
return true;
}
async openRecord(record: MapRecord, remix: boolean) {
if (store.state.busy || !(await this.replaceAllowed())) return;
if (!runtimeHasDomain('resource'))
throw new Error('Map downloads are unavailable here. Try another host.');
const epoch = this.#identityEpoch,
version = this.#workspaceVersion,
request = ++this.#requestEpoch;
this.#library({ status: 'Opening map…' });
const loaded = await loadMap(record);
if (
epoch !== this.#identityEpoch ||
request !== this.#requestEpoch ||
version !== this.#workspaceVersion ||
store.state.busy
)
return;
if (remix) {
loaded.id = freshId(loaded.id);
this.setMap(
loaded,
{
remixOf: record.event,
remixRelay: record.relayHints[0],
content: record.metadata.content,
hashtags: record.metadata.hashtags,
},
true,
);
} else {
if (record.event.pubkey !== store.state.viewer)
throw new Error('Only the author can edit this map. Use Remix.');
this.setMap(loaded, {
base: record,
content: record.metadata.content,
hashtags: record.metadata.hashtags,
image: record.metadata.image ?? undefined,
});
}
this.ui.library?.close();
this.status(
remix
? 'Remix ready. Parent, original and author credits will be preserved.'
: 'Map opened. Publishing will save your changes to this map.',
'success',
);
}
// ---- drafts and publication ----
#draft() {
const { base, remixOf, remixRelay, content, hashtags, image } = store.state;
return {
map: cloneMap(this.map),
base,
remixOf,
remixRelay,
content,
hashtags,
image,
savedAt: Date.now(),
};
}
async saveDraft() {
const key = store.state.viewer || 'guest';
const epoch = this.#identityEpoch,
version = this.#workspaceVersion;
await saveDraft(key, this.#draft());
if (epoch !== this.#identityEpoch) return;
if (version === this.#workspaceVersion) store.set({ dirty: false });
this.status('Draft saved.', 'success');
}
async restoreDraft() {
if (store.state.busy) return;
if (!runtimeHasDomain('storage'))
throw new Error('Draft storage is unavailable in this host.');
const epoch = this.#identityEpoch;
const saved = await loadDraft(store.state.viewer || 'guest');
if (epoch !== this.#identityEpoch) return;
if (!saved) {
this.#library({ status: 'No saved draft for this identity.' });
return;
}
if (!(await this.replaceAllowed())) return;
if (epoch !== this.#identityEpoch || store.state.busy) return;
this.setMap(saved.map, saved);
this.ui.library?.close();
this.status('Saved draft restored.', 'success');
}
async publish() {
if (store.state.busy) return;
store.set({ publication: { phase: 'idle', message: '', stage: 0, warnings: [] } });
const epoch = this.#identityEpoch;
const update = (patch: Partial<EditorState['publication']>) =>
store.set({ publication: { ...store.state.publication, ...patch } });
const warn = (message: string) =>
update({ warnings: [...store.state.publication.warnings, message] });
try {
const errors = validatePlayable(this.map);
store.set({ validation: errors.map(friendlyError).join('\n') });
if (errors.length)
throw new Error('Check your map and clear a path to extraction before publishing.');
const { viewer, base } = store.state;
if (!viewer) throw new Error('Connect your Nostr identity in the host first.');
if (
!(await this.confirmAction(
`${base ? 'Update' : 'Publish'} ${this.map.name} using your account? Your map and creator credits will be public. You may be asked to approve sharing.`,
base ? 'Update map' : 'Publish map',
))
)
return;
if (store.state.busy) return;
if (store.state.viewer !== viewer || this.#identityEpoch !== epoch)
throw new Error('Identity changed during confirmation. Review and confirm again.');
const snapshot = this.#draft();
store.set({
busy: true,
publication: {
phase: 'working',
message: 'Saving a draft backup…',
stage: 0,
warnings: [],
},
});
this.ui.publication?.show();
if (runtimeHasDomain('storage')) {
try {
await saveDraft(viewer, snapshot);
} catch {
warn(
'A draft backup could not be saved. Keep this window open or download your map.',
);
}
}
if (epoch !== this.#identityEpoch)
throw new Error(
'Identity changed before publication. Retry under the intended account.',
);
const published = await publishMap(snapshot.map, {
base: snapshot.base,
remixOf: snapshot.remixOf,
remixRelay: snapshot.remixRelay,
content: snapshot.content,
hashtags: snapshot.hashtags,
image: snapshot.image,
onStatus: (message) => {
if (epoch !== this.#identityEpoch) return;
const stage = /Sharing your map/.test(message)
? 2
: /Uploading|Waiting for sharing/.test(message)
? 1
: 0;
update({ message, stage });
this.status(message);
},
});
if (epoch !== this.#identityEpoch)
throw new Error(
'Your account changed during publication. Open My maps to confirm the result before retrying.',
);
this.#publishReceipt = published.event;
store.set({
base: published,
remixOf: undefined,
remixRelay: undefined,
dirty: false,
publishDetails: JSON.stringify(published.event, null, 2),
});
update({
phase: 'success',
message: `${published.metadata.title} published successfully.`,
stage: 3,
});
this.status(`${published.metadata.title} published.`, 'success');
if (runtimeHasDomain('storage')) {
try {
await saveDraft(viewer, this.#draft());
} catch {
warn('Your map was published, but the local draft backup could not be saved.');
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (epoch === this.#identityEpoch) {
const details = error as { upload?: unknown; template?: unknown };
this.#publishReceipt = {
upload: details?.upload,
template: details?.template,
error: message,
};
store.set({
publishDetails: JSON.stringify(this.#publishReceipt, null, 2),
dirty: true,
});
this.fail(error);
}
update({
phase: 'error',
message:
epoch === this.#identityEpoch
? friendlyError(error)
: 'Your account changed. Open My maps to check whether publication completed before retrying.',
error: message,
});
this.ui.publication?.show();
} finally {
store.set({ busy: false });
if (this.#pendingIntent) void this.processPendingIntent();
}
}
// ---- identity and inbound map requests ----
identityChanged = (pubkey: string) => {
if (pubkey === store.state.viewer) return;
this.#identityEpoch++;
this.#requestEpoch++;
this.#nextUntil = undefined;
const base = store.state.base;
store.set({
viewer: pubkey,
identityLabel: pubkey ? 'Signed in' : 'Sign in to share maps',
library: { ...store.state.library, view: 'none', records: [], loadMore: false },
});
if (base && base.event.pubkey !== pubkey)
this.status(
'Identity changed. This published map belongs to another account. Export it or create a remix; updates are disabled.',
'error',
);
else
this.status(
pubkey
? 'Identity connected. Open Maps to load your published maps.'
: 'Connect your Nostr identity in the host to manage published maps. Local authoring is available.',
);
if (this.#pendingIntent) void this.processPendingIntent();
};
get identityEpoch() {
return this.#identityEpoch;
}
setIdentityLabel(identityLabel: string) {
store.set({ identityLabel });
}
tutorialClosed() {
// Closing by any route marks it seen; a failed write only means it may auto-open again.
if (runtimeHasDomain('storage')) saveTutorialSeen().catch(() => {});
}
confirmClosed() {
setTimeout(() => {
if (this.#pendingIntent) void this.processPendingIntent();
}, 0);
}
receiveMapIntent = async (request: MapIntent) => {
this.#pendingIntent = request;
this.#intentVersion++;
if (store.state.busy || this.#intentInProgress) {
this.status('Another napplet requested a map. It will open after the current map.');
return;
}
await this.processPendingIntent();
};
#closeOverlays() {
this.ui.library?.close();
this.ui.help?.close();
}
async processPendingIntent() {
if (
!this.#pendingIntent ||
store.state.busy ||
this.#intentInProgress ||
this.ui.confirm?.isOpen ||
this.ui.publication?.isOpen
)
return;
const request = this.#pendingIntent;
if (request.action !== 'new' && !store.state.viewer) {
this.status(
'A napplet requested a published map. Connect your Nostr identity in the host to open it.',
);
return;
}
this.#pendingIntent = undefined;
this.#intentInProgress = true;
const deliveryVersion = this.#intentVersion,
accountVersion = this.#identityEpoch,
workspace = this.#workspaceVersion;
const stale = () =>
deliveryVersion !== this.#intentVersion ||
accountVersion !== this.#identityEpoch ||
workspace !== this.#workspaceVersion ||
store.state.busy;
try {
if (!(await this.replaceAllowed())) {
this.status('Map request cancelled. Your workspace is unchanged.');
return;
}
if (stale()) return;
if (request.action === 'new') {
this.setMap(request.map ?? createMap(`map-${Date.now().toString(36)}`), {}, true);
this.#closeOverlays();
this.status(
'New map opened from another napplet. Review the map before publishing.',
);
return;
}
const source = await resolveMap(request.target!);
if (stale()) return;
let remix = request.action === 'remix';
if (source.event.pubkey !== store.state.viewer && !remix) {
if (request.action === 'edit')
throw new Error(
'This map belongs to another player. Open Maps and choose Remix to make your own version.',
);
if (
!(await this.confirmAction(
'This map belongs to another player. Create your own remix with the original author credits?',
'Open as remix',
))
)
return;
remix = true;
}
if (stale()) return;
const value = await loadMap(source);
if (stale()) return;
this.#closeOverlays();
this.setWorkspaceTab('map');
if (remix) {
value.id = freshId(value.id);
this.setMap(
value,
{
remixOf: source.event,
remixRelay: source.relayHints[0],
content: source.metadata.content,
hashtags: source.metadata.hashtags,
},
true,
);
} else
this.setMap(value, {
base: source,
content: source.metadata.content,
hashtags: source.metadata.hashtags,
image: source.metadata.image ?? undefined,
});
this.status(
remix
? 'Your remix is ready. Original creator credits are preserved.'
: 'Your map is open for editing.',
'success',
);
} catch (error) {
this.fail(error);
} finally {
this.#intentInProgress = false;
if (this.#pendingIntent) void this.processPendingIntent();
}
}
}
export const editor = new EditorController();
/** Shorthand for template handlers that report errors in the status line. */
export const guard = editor.guard;
