pull/256/head
Candid Dauth 2023-11-11 08:01:40 +01:00
rodzic 0d4760bf4b
commit a7b7693b43
80 zmienionych plików z 1603 dodań i 2005 usunięć

Wyświetl plik

@ -36,12 +36,12 @@
"socket.io-client": "^4.7.2"
},
"devDependencies": {
"@types/geojson": "^7946.0.11",
"@types/rollup-plugin-auto-external": "^2.0.3",
"@types/geojson": "^7946.0.13",
"@types/rollup-plugin-auto-external": "^2.0.5",
"rimraf": "^5.0.5",
"rollup-plugin-auto-external": "^2.0.0",
"typescript": "^5.2.2",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0"
"vite": "^4.5.0",
"vite-plugin-dts": "^3.6.3"
}
}

Wyświetl plik

@ -40,14 +40,11 @@
"dependencies": {
"@ckpack/vue-color": "^1.5.0",
"@tmcw/togeojson": "^5.8.1",
"@vitejs/plugin-vue": "^4.4.0",
"@vitejs/plugin-vue": "^4.4.1",
"blob": "^0.1.0",
"bootstrap": "^5.3.2",
"bootstrap-touchspin": "^4.7.3",
"clipboard": "^2.0.11",
"copy-to-clipboard": "^3.3.3",
"decode-uri-component": "^0.4.1",
"domutils": "^3.1.0",
"facilmap-client": "workspace:^",
"facilmap-leaflet": "workspace:^",
"facilmap-types": "workspace:^",
@ -55,10 +52,9 @@
"file-saver": "^2.0.5",
"hammerjs": "^2.0.8",
"jquery": "^3.7.1",
"jquery-ui": "^1.13.2",
"leaflet": "^1.9.4",
"leaflet-draggable-lines": "^1.2.1",
"leaflet-graphicscale": "^0.0.2",
"leaflet-graphicscale": "^0.0.4",
"leaflet-mouse-position": "^1.2.0",
"leaflet.heightgraph": "^1.4.0",
"leaflet.locatecontrol": "^0.79.0",
@ -71,41 +67,29 @@
"popper-max-size-modifier": "^0.2.0",
"rollup-plugin-auto-external": "^2.0.0",
"tablesorter": "^2.31.3",
"vite": "^4.4.11",
"vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0",
"vite-plugin-dts": "^3.6.0",
"vue": "^3.3.4",
"vite-plugin-dts": "^3.6.3",
"vue": "^3.3.8",
"vuedraggable": "next"
},
"devDependencies": {
"@types/bootstrap": "^5.2.9",
"@types/decode-uri-component": "^0.2.0",
"@types/file-saver": "^2.0.5",
"@types/hammerjs": "^2.0.42",
"@types/jquery": "^3.5.21",
"@types/leaflet": "^1.9.6",
"@types/leaflet-mouse-position": "^1.2.2",
"@types/leaflet.locatecontrol": "^0.74.2",
"@types/lodash-es": "^4.17.9",
"@types/pluralize": "^0.0.31",
"@types/scrollparent": "^2.0.1",
"css-loader": "^6.8.1",
"ejs-compiled-loader": "^3.1.0",
"happy-dom": "^12.9.1",
"html-loader": "^4.2.0",
"mini-svg-data-uri": "^1.4.4",
"@types/decode-uri-component": "^0.2.2",
"@types/file-saver": "^2.0.7",
"@types/hammerjs": "^2.0.44",
"@types/jquery": "^3.5.27",
"@types/leaflet": "^1.9.8",
"@types/leaflet-mouse-position": "^1.2.4",
"@types/leaflet.locatecontrol": "^0.74.4",
"@types/lodash-es": "^4.17.11",
"@types/pluralize": "^0.0.33",
"happy-dom": "^12.10.3",
"rimraf": "^5.0.5",
"sass": "^1.68.0",
"sass-loader": "^13.3.2",
"source-map-loader": "^4.0.1",
"style-loader": "^3.3.3",
"svgo": "^3.0.2",
"ts-loader": "^9.4.4",
"svgo": "^3.0.3",
"ts-node": "^10.9.1",
"typescript": "^5.2.2",
"vitest": "^0.34.6",
"vue-template-compiler": "^2.7.14",
"vue-template-loader": "^1.1.0",
"vue-tsc": "^1.8.22"
}
}

Wyświetl plik

@ -31,8 +31,8 @@
>
<p><a href="https://github.com/facilmap/facilmap" target="_blank"><strong>FacilMap</strong></a> is available under the <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank">GNU Affero General Public License, Version 3</a>.</p>
<p>If something does not work or you have a suggestion for improvement, please report on the <a href="https://github.com/FacilMap/facilmap/issues" target="_blank">issue tracker</a>.</p>
<p>If you have a question, please have a look at the <a href="https://docs.facilmap.org/users/">documentation</a> or raise a question in the <a href="https://github.com/FacilMap/facilmap/discussions">discussion forum</a>.</p>
<p><a href="https://docs.facilmap.org/users/privacy/">Privacy information</a></p>
<p>If you have a question, please have a look at the <a href="https://docs.facilmap.org/users/" target="_blank">documentation</a>, raise a question in the <a href="https://github.com/FacilMap/facilmap/discussions" target="_blank">discussion forum</a> or ask in the <a href="https://matrix.to/#/#facilmap:rankenste.in" target="_blank">Matrix chat</a>.</p>
<p><a href="https://docs.facilmap.org/users/privacy/" target="_blank">Privacy information</a></p>
<h4>Map data</h4>
<dl class="row">
<template v-for="layer in layers">
@ -60,17 +60,18 @@
<li><a href="https://sequelize.org/" target="_blank">Sequelize</a></li>
<li><a href="https://socket.io/" target="_blank">socket.io</a></li>
<li><a href="https://www.typescriptlang.org/" target="_blank">TypeScript</a></li>
<li><a href="https://webpack.js.org/" target="_blank">Webpack</a></li>
<li><a href="https://jquery.com/" target="_blank">jQuery</a></li>
<li><a href="https://vuejs.org/" target="_blank">Vue.js</a></li>
<li><a href="https://github.com/chjj/marked" target="_blank">Marked</a></li>
<li><a href="https://vitejs.dev/" target="_blank">Vite</a></li>
<li><a href="https://getbootstrap.com/" target="_blank">Bootstrap</a></li>
<li><a href="https://bootstrap-vue.org/" target="_blank">BootstrapVue</a></li>
<li><a href="https://leafletjs.com/" target="_blank">Leaflet</a></li>
<li><a href="http://project-osrm.org/" target="_blank">OSRM</a></li>
<li><a href="https://openrouteservice.org/" target="_blank">OpenRouteService</a></li>
<li><a href="https://nominatim.openstreetmap.org/" target="_blank">Nominatim</a></li>
<li><a href="https://github.com/joewalnes/filtrex" target="_blank">Filtrex</a></li>
<li><a href="https://github.com/chjj/marked" target="_blank">Marked</a></li>
<li><a href="https://github.com/cure53/DOMPurify" target="_blank">DOMPurify</a></li>
<li><a href="https://expressjs.com/" target="_blank">Express</a></li>
<li><a href="https://vuepress.vuejs.org/" target="_blank">Vuepress</a></li>
</ul>
<h4>Icons</h4>
<ul>

Wyświetl plik

@ -1,13 +1,14 @@
<script setup lang="ts">
import type { Point, SearchResult } from "facilmap-types";
import type { SearchResult } from "facilmap-types";
import { round } from "facilmap-utils";
import { SearchResultsLayer } from "facilmap-leaflet";
import SearchResultInfo from "./search-result-info.vue";
import { Util } from "leaflet";
import { computed, markRaw, nextTick, ref, shallowReactive, watch } from "vue";
import { computed, markRaw, nextTick, readonly, ref, shallowReactive, toRef, watch } from "vue";
import { useEventListener } from "../utils/utils";
import SearchBoxTab from "./search-box/search-box-tab.vue"
import { injectContextRequired, requireClientContext, requireMapContext, requireSearchBoxContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
import type { WritableClickMarkerTabContext } from "./facil-map-context-provider/click-marker-tab-context";
const context = injectContextRequired();
const mapContext = requireMapContext(context);
@ -19,7 +20,6 @@
const activeResults = ref<SearchResult[]>([]);
const layers = shallowReactive<SearchResultsLayer[]>([]);
useEventListener(mapContext, "map-long-click", handleMapLongClick);
useEventListener(mapContext, "open-selection", handleOpenSelection);
const layerIds = computed(() => layers.map((layer) => Util.stamp(layer)));
@ -31,44 +31,48 @@
}
});
const clickMarkerTabContext = ref<WritableClickMarkerTabContext>({
async openClickMarker(point) {
const now = Date.now();
lastClick = now;
const results = await client.value.find({
query: `geo:${round(point.lat, 5)},${round(point.lon, 5)}?z=${mapContext.value.zoom}`,
loadUrls: false,
elevation: true
});
if (now !== lastClick) {
// There has been another click since the one we are reacting to.
return;
}
if (results.length > 0) {
const layer = new SearchResultsLayer([results[0]]).addTo(mapContext.value.components.map);
mapContext.value.components.selectionHandler.addSearchResultLayer(layer);
activeResults.value.push(results[0]);
layers.push(markRaw(layer));
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "searchResult", result: results[0], layerId: Util.stamp(layer) }]);
await nextTick();
searchBoxContext.value.activateTab(`fm${context.id}-click-marker-tab-${activeResults.value.length - 1}`, { expand: true });
}
}
});
context.provideComponent("clickMarkerTab", toRef(readonly(clickMarkerTabContext)));
function handleOpenSelection(): void {
for (let i = 0; i < layerIds.value.length; i++) {
if (mapContext.value.selection.some((item) => item.type == "searchResult" && item.layerId == layerIds.value[i])) {
searchBoxContext.value.activateTab(`fm${context.id}-click-marker-tab-${i}`);
searchBoxContext.value.activateTab(`fm${context.id}-click-marker-tab-${i}`, { expand: true });
break;
}
}
}
async function handleMapLongClick({ point }: { point: Point }): Promise<void> {
const now = Date.now();
lastClick = now;
const results = await client.value.find({
query: `geo:${round(point.lat, 5)},${round(point.lon, 5)}?z=${mapContext.value.zoom}`,
loadUrls: false,
elevation: true
});
if (now !== lastClick) {
// There has been another click since the one we are reacting to.
return;
}
if (results.length > 0) {
const layer = new SearchResultsLayer([results[0]]).addTo(mapContext.value.components.map);
mapContext.value.components.selectionHandler.addSearchResultLayer(layer);
activeResults.value.push(results[0]);
layers.push(markRaw(layer));
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "searchResult", result: results[0], layerId: Util.stamp(layer) }]);
await nextTick();
searchBoxContext.value.activateTab(`fm${context.id}-click-marker-tab-${activeResults.value.length - 1}`);
}
}
function close(result: SearchResult): void {
const idx = activeResults.value.indexOf(result);
if (idx == -1)

Wyświetl plik

@ -52,9 +52,9 @@
toasts.hideToast(`fm${context.id}-client-deleted`);
createId.value = undefined;
if (props.padId)
toasts.showToast(`fm${context.id}-client-connecting`, "Loading", "Loading map…", { spinner: true });
toasts.showToast(`fm${context.id}-client-connecting`, "Loading", "Loading map…", { spinner: true, noCloseButton: true });
else
toasts.showToast(`fm${context.id}-client-connecting`, "Connecting", "Connecting to server…", { spinner: true });
toasts.showToast(`fm${context.id}-client-connecting`, "Connecting", "Connecting to server…", { spinner: true, noCloseButton: true });
const newClient: ClientContext = Object.assign(new ReactiveClient(props.serverUrl, props.padId), {
openPad
@ -82,6 +82,7 @@
newClient.on("deletePad", () => {
toasts.showToast(`fm${context.id}-client-deleted`, "Map deleted", "This map has been deleted.", {
noCloseButton: true,
variant: "danger",
actions: context.settings.interactive ? [
{

Wyświetl plik

@ -38,13 +38,13 @@
class="fm-edit-filter"
:isModified="isModified"
@submit="save"
okLabel="Apply"
:okLabel="isModified ? 'Apply' : undefined"
ref="modalRef"
@hidden="emit('hidden')"
>
<p>Here you can set an advanced expression to show/hide certain markers/lines based on their attributes. The filter expression only applies to your view of the map, but it can be persisted as part of a saved view or a shared link.</p>
<div class="was-validated">
<div :class="{ 'was-validated': filter }">
<textarea
class="form-control text-monospace"
v-model="filter"

Wyświetl plik

@ -3,10 +3,10 @@
import { canControl, getUniqueId, mergeObject, validateRequired } from "../utils/utils";
import { cloneDeep, isEqual, omit } from "lodash-es";
import ModalDialog from "./ui/modal-dialog.vue";
import ColourField from "./ui/colour-field.vue";
import ColourPicker from "./ui/colour-picker.vue";
import FieldInput from "./ui/field-input.vue";
import RouteMode from "./ui/route-mode.vue";
import WidthField from "./ui/width-field.vue";
import WidthPicker from "./ui/width-picker.vue";
import { computed, ref, toRef, watch } from "vue";
import { useToasts } from "./ui/toasts/toasts.vue";
import DropdownMenu from "./ui/dropdown-menu.vue";
@ -89,11 +89,11 @@
<div class="row mb-3">
<label :for="`${id}-colour-input`" class="col-sm-3 col-form-label">Colour</label>
<div class="col-sm-9">
<ColourField
<ColourPicker
:id="`${id}-colour-input`"
v-model="line.colour"
:validationError="colourValidationError"
></ColourField>
></ColourPicker>
</div>
</div>
</template>
@ -102,11 +102,11 @@
<div class="row mb-3">
<label :for="`${id}-width-input`" class="col-sm-3 col-form-label">Width</label>
<div class="col-sm-9">
<WidthField
<WidthPicker
:id="`${id}-width-input`"
v-model="line.width"
class="fm-form-range-with-label"
></WidthField>
></WidthPicker>
</div>
</div>
</template>

Wyświetl plik

@ -3,11 +3,11 @@
import { canControl, getUniqueId, mergeObject, validateRequired } from "../utils/utils";
import { cloneDeep, isEqual } from "lodash-es";
import ModalDialog from "./ui/modal-dialog.vue";
import ColourField from "./ui/colour-field.vue";
import SymbolField from "./ui/symbol-field.vue";
import ShapeField from "./ui/shape-field.vue";
import ColourPicker from "./ui/colour-picker.vue";
import SymbolPicker from "./ui/symbol-picker.vue";
import ShapePicker from "./ui/shape-picker.vue";
import FieldInput from "./ui/field-input.vue";
import SizeField from "./ui/size-field.vue";
import SizePicker from "./ui/size-picker.vue";
import { computed, ref, toRef, watch } from "vue";
import { useToasts } from "./ui/toasts/toasts.vue";
import DropdownMenu from "./ui/dropdown-menu.vue";
@ -82,11 +82,11 @@
<div class="row mb-3">
<label :for="`${id}-colour-input`" class="col-sm-3 col-form-label">Colour</label>
<div class="col-sm-9">
<ColourField
<ColourPicker
:id="`${id}-colour-input`"
v-model="marker.colour"
:validationError="colourValidationError"
></ColourField>
></ColourPicker>
</div>
</div>
</template>
@ -95,11 +95,11 @@
<div class="row mb-3">
<label :for="`${id}-size-input`" class="col-sm-3 col-form-label">Size</label>
<div class="col-sm-9">
<SizeField
<SizePicker
:id="`${id}-size-input`"
v-model="marker.size"
class="fm-form-range-with-label"
></SizeField>
></SizePicker>
</div>
</div>
</template>
@ -108,7 +108,7 @@
<div class="row mb-3">
<label :for="`${id}-symbol-input`" class="col-sm-3 col-form-label">Icon</label>
<div class="col-sm-9">
<SymbolField :id="`${id}-symbol-input`" v-model="marker.symbol"></SymbolField>
<SymbolPicker :id="`${id}-symbol-input`" v-model="marker.symbol"></SymbolPicker>
</div>
</div>
</template>
@ -117,7 +117,7 @@
<div class="row mb-3">
<label :for="`${id}-shape-input`" class="col-sm-3 col-form-label">Shape</label>
<div class="col-sm-9">
<ShapeField :id="`${id}-shape-input`" v-model="marker.shape"></ShapeField>
<ShapePicker :id="`${id}-shape-input`" v-model="marker.shape"></ShapePicker>
</div>
</div>
</template>

Wyświetl plik

@ -4,15 +4,15 @@
import { mergeTypeObject } from "./edit-type-utils";
import { cloneDeep, isEqual } from "lodash-es";
import { useToasts } from "../ui/toasts/toasts.vue";
import ColourField from "../ui/colour-field.vue";
import ShapeField from "../ui/shape-field.vue";
import SymbolField from "../ui/symbol-field.vue";
import ColourPicker from "../ui/colour-picker.vue";
import ShapePicker from "../ui/shape-picker.vue";
import SymbolPicker from "../ui/symbol-picker.vue";
import RouteMode from "../ui/route-mode.vue";
import Draggable from "vuedraggable";
import FieldInput from "../ui/field-input.vue";
import Icon from "../ui/icon.vue";
import WidthField from "../ui/width-field.vue";
import SizeField from "../ui/size-field.vue";
import WidthPicker from "../ui/width-picker.vue";
import SizePicker from "../ui/size-picker.vue";
import EditTypeDropdownDialog from "./edit-type-dropdown-dialog.vue";
import { computed, ref, watch } from "vue";
import ModalDialog from "../ui/modal-dialog.vue";
@ -77,7 +77,12 @@
}
async function deleteField(field: Field): Promise<void> {
if (!await showConfirm({ title: "Delete field", message: `Do you really want to delete the field “${field.name}”?` }))
if (!await showConfirm({
title: "Delete field",
message: `Do you really want to delete the field “${field.name}”?`,
variant: "danger",
okLabel: "Delete"
}))
return;
var idx = type.value.fields.indexOf(field);
@ -178,6 +183,7 @@
class="fm-edit-type"
:isModified="isModified"
:isCreate="isCreate"
:stackLevel="1"
@submit="$event.waitUntil(save())"
@hidden="emit('hidden')"
>
@ -225,11 +231,11 @@
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<ColourField
<ColourPicker
:id="`${id}-default-colour-input`"
v-model="type.defaultColour"
:validationError="defaultColourValidationError"
></ColourField>
></ColourPicker>
</div>
<div class="col-sm-3">
<div class="form-check">
@ -253,11 +259,11 @@
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<SizeField
<SizePicker
:id="`${id}-default-size-input`"
v-model="type.defaultSize"
:validationError="defaultSizeValidationError"
></SizeField>
></SizePicker>
</div>
<div class="col-sm-3">
<div class="form-check">
@ -281,11 +287,11 @@
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<SymbolField
<SymbolPicker
:id="`${id}-default-symbol-input`"
v-model="type.defaultSymbol"
:validationError="defaultSymbolValidationError"
></SymbolField>
></SymbolPicker>
</div>
<div class="col-sm-3">
<div class="form-check">
@ -309,11 +315,11 @@
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<ShapeField
<ShapePicker
:id="`${id}-default-shape-input`"
v-model="type.defaultShape"
:validationError="defaultShapeValidationError"
></ShapeField>
></ShapePicker>
</div>
<div class="col-sm-3">
<div class="form-check">
@ -337,11 +343,11 @@
<div class="col-sm-9">
<div class="row align-items-center">
<div class="col-sm-9">
<WidthField
<WidthPicker
:id="`${id}-default-width-input`"
v-model="type.defaultWidth"
:validationError="defaultWidthValidationError"
></WidthField>
></WidthPicker>
</div>
<div class="col-sm-3">
<div class="form-check">

Wyświetl plik

@ -2,14 +2,14 @@
import type { Field, FieldOption, FieldOptionUpdate, FieldUpdate, Type } from "facilmap-types";
import { canControl, getUniqueId, mergeObject, validateRequired } from "../../utils/utils";
import { cloneDeep, isEqual } from "lodash-es";
import ColourField from "../ui/colour-field.vue";
import ColourPicker from "../ui/colour-picker.vue";
import Draggable from "vuedraggable";
import Icon from "../ui/icon.vue";
import ModalDialog from "../ui/modal-dialog.vue";
import ShapeField from "../ui/shape-field.vue";
import SizeField from "../ui/size-field.vue";
import SymbolField from "../ui/symbol-field.vue";
import WidthField from "../ui/width-field.vue";
import ShapePicker from "../ui/shape-picker.vue";
import SizePicker from "../ui/size-picker.vue";
import SymbolPicker from "../ui/symbol-picker.vue";
import WidthPicker from "../ui/width-picker.vue";
import { useToasts } from "../ui/toasts/toasts.vue";
import { computed, ref, watch } from "vue";
import { showConfirm } from "../ui/alert.vue";
@ -96,7 +96,12 @@
}
async function deleteOption(option: FieldOptionUpdate): Promise<void> {
if (!await showConfirm({ title: "Delete option", message: `Do you really want to delete the option “${option.value}”?` }))
if (!await showConfirm({
title: "Delete option",
message: `Do you really want to delete the option “${option.value}”?`,
variant: "danger",
okLabel: "Delete"
}))
return;
var idx = fieldValue.value.options!.indexOf(option);
@ -144,7 +149,8 @@
@submit="save()"
@hidden="emit('hidden')"
:size="fieldValue && controlNumber > 2 ? 'xl' : 'lg'"
okLabel="OK"
:okLabel="isModified ? 'OK' : undefined"
:stackLevel="2"
>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Control</label>
@ -262,19 +268,19 @@
</div>
</td>
<td v-if="fieldValue.controlColour" class="field">
<ColourField v-model="option.colour" :validationError="optionValidationErrors![idx].colour"></ColourField>
<ColourPicker v-model="option.colour" :validationError="optionValidationErrors![idx].colour"></ColourPicker>
</td>
<td v-if="fieldValue.controlSize" class="field">
<SizeField v-model="option.size" :validationError="optionValidationErrors![idx].colour"></SizeField>
<SizePicker v-model="option.size" :validationError="optionValidationErrors![idx].colour"></SizePicker>
</td>
<td v-if="fieldValue.controlSymbol" class="field">
<SymbolField v-model="option.symbol"></SymbolField>
<SymbolPicker v-model="option.symbol"></SymbolPicker>
</td>
<td v-if="fieldValue.controlShape" class="field">
<ShapeField v-model="option.shape"></ShapeField>
<ShapePicker v-model="option.shape"></ShapePicker>
</td>
<td v-if="fieldValue.controlWidth" class="field">
<WidthField v-model="option.width" :validationError="optionValidationErrors![idx].width"></WidthField>
<WidthPicker v-model="option.width" :validationError="optionValidationErrors![idx].width"></WidthPicker>
</td>
<td v-if="fieldValue.type != 'checkbox'" class="td-buttons">
<button type="button" class="btn btn-secondary" @click="deleteOption(option)"><Icon icon="minus" alt="Remove"></Icon></button>

Wyświetl plik

@ -0,0 +1,7 @@
import type { Point } from "facilmap-types";
export interface WritableClickMarkerTabContext {
openClickMarker(point: Point): Promise<void>;
}
export type ClickMarkerTabContext = Readonly<WritableClickMarkerTabContext>;

Wyświetl plik

@ -46,7 +46,7 @@
const components = shallowReactive<FacilMapComponents>({});
function provideComponent<K extends keyof FacilMapComponents>(key: K, componentRef: Ref<FacilMapComponents[K]>) {
function provideComponent<K extends keyof FacilMapComponents>(key: K, componentRef: Readonly<Ref<FacilMapComponents[K]>>) {
if (key in components) {
throw new Error(`Component "${key}"" is already provided.`);
}

Wyświetl plik

@ -2,6 +2,10 @@ import type { Ref } from "vue";
import type { ClientContext } from "./client-context";
import type { MapContext } from "./map-context";
import type { SearchBoxContext } from "./search-box-context";
import type { SearchFormTabContext } from "./search-form-tab-context";
import type { RouteFormTabContext } from "./route-form-tab-context";
import type { ClickMarkerTabContext } from "./click-marker-tab-context";
import type { ImportTabContext } from "./import-tab-context";
export interface FacilMapSettings {
toolbox: boolean;
@ -17,6 +21,10 @@ export interface FacilMapComponents {
searchBox?: SearchBoxContext;
client?: ClientContext;
map?: MapContext;
searchFormTab?: SearchFormTabContext;
routeFormTab?: RouteFormTabContext;
clickMarkerTab?: ClickMarkerTabContext;
importTab?: ImportTabContext;
}
export interface WritableFacilMapContext {
@ -25,7 +33,7 @@ export interface WritableFacilMapContext {
isNarrow: boolean;
settings: FacilMapSettings;
components: FacilMapComponents;
provideComponent<K extends keyof FacilMapComponents>(key: K, componentRef: Ref<FacilMapComponents[K]>): void;
provideComponent<K extends keyof FacilMapComponents>(key: K, componentRef: Readonly<Ref<FacilMapComponents[K]>>): void;
}
export type FacilMapContext = Readonly<Omit<WritableFacilMapContext, "settings" | "components">> & {

Wyświetl plik

@ -0,0 +1,5 @@
export interface WritableImportTabContext {
openFilePicker: () => void;
}
export type ImportTabContext = Readonly<WritableImportTabContext>;

Wyświetl plik

@ -1,4 +1,3 @@
import type { FindOnMapResult, Point, SearchResult } from "facilmap-types";
import type { BboxHandler, HashHandler, HashQuery, LinesLayer, MarkersLayer, OverpassLayer, OverpassPreset, SearchResultsLayer, VisibleLayers } from "facilmap-leaflet";
import type { LatLng, LatLngBounds, Map } from "leaflet";
import type { FilterFunc } from "facilmap-utils";
@ -7,22 +6,8 @@ import type { DeepReadonly } from "vue";
import type { SelectedItem } from "../../utils/selection";
import type SelectionHandler from "../../utils/selection";
export interface RouteDestination {
query: string;
searchSuggestions?: SearchResult[];
mapSuggestions?: FindOnMapResult[];
selectedSuggestion?: SearchResult | FindOnMapResult;
}
export type MapContextEvents = {
"import-file": void;
"open-selection": { selection: DeepReadonly<SelectedItem[]> };
"search-set-query": { query: string; zoom?: boolean; smooth?: boolean };
"route-set-query": { query: string; zoom?: boolean; smooth?: boolean };
"route-set-from": RouteDestination;
"route-add-via": RouteDestination;
"route-set-to": RouteDestination;
"map-long-click": { point: Point };
};
export interface MapComponents {

Wyświetl plik

@ -0,0 +1,17 @@
import type { FindOnMapResult, SearchResult } from "facilmap-types";
export interface RouteDestination {
query: string;
searchSuggestions?: SearchResult[];
mapSuggestions?: FindOnMapResult[];
selectedSuggestion?: SearchResult | FindOnMapResult;
}
export interface WritableRouteFormTabContext {
setQuery(query: string, zoom?: boolean, smooth?: boolean): void;
setFrom(destination: RouteDestination): void;
addVia(destination: RouteDestination): void;
setTo(destination: RouteDestination): void;
}
export type RouteFormTabContext = Readonly<WritableRouteFormTabContext>;

Wyświetl plik

@ -7,7 +7,6 @@ export type SearchBoxEventMap = {
"resize": void;
"resizeend": void;
"resizereset": void;
"expand": void;
}
export interface SearchBoxTab {
@ -23,7 +22,7 @@ export interface SearchBoxContextData {
activeTabId: string | undefined;
activeTab: SearchBoxTab | undefined;
provideTab: (id: string, tabRef: Ref<SearchBoxTab>) => void;
activateTab: (id: string, expand?: boolean) => void;
activateTab: (id: string, options?: { expand?: boolean; autofocus?: boolean }) => void;
}
export type WritableSearchBoxContext = SearchBoxContextData & Emitter<SearchBoxEventMap>;

Wyświetl plik

@ -0,0 +1,5 @@
export interface WritableSearchFormTabContext {
setQuery(query: string, zoom?: boolean, smooth?: boolean, autofocus?: boolean): void;
}
export type SearchFormTabContext = Readonly<WritableSearchFormTabContext>;

Wyświetl plik

@ -140,7 +140,6 @@
</ul>
</template>
</template>
</SearchResults>
</div>
</template>

Wyświetl plik

@ -3,7 +3,7 @@
import type { HistoryEntry, ID } from "facilmap-types";
import { orderBy } from "lodash-es";
import Icon from "../ui/icon.vue";
import { computed, onScopeDispose, reactive, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { useToasts } from "../ui/toasts/toasts.vue";
import { showConfirm } from "../ui/alert.vue";
import { mapRef } from "../../utils/vue";
@ -33,15 +33,17 @@
const id = getUniqueId("fm-history-dialog");
try {
await client.value.listenToHistory();
} catch (err) {
toasts.showErrorToast(`${id}-listen-error`, "Error loading history", err);
} finally {
isLoading.value = false;
}
onMounted(async () => {
try {
await client.value.listenToHistory();
} catch (err) {
toasts.showErrorToast(`${id}-listen-error`, "Error loading history", err);
} finally {
isLoading.value = false;
}
});
onScopeDispose(async () => {
onBeforeUnmount(async () => {
try {
await client.value.stopListeningToHistory();
} catch (err) {
@ -52,7 +54,12 @@
async function revert(entry: HistoryEntryWithLabels): Promise<void> {
toasts.hideToast(`${id}-revert-error`);
if (!await showConfirm({ title: entry.labels.button, message: entry.labels.confirm }))
if (!await showConfirm({
title: entry.labels.revert!.button,
message: entry.labels.revert!.message,
variant: "warning",
okLabel: entry.labels.revert!.okLabel
}))
return;
isReverting.value = entry;
@ -82,7 +89,7 @@
const isShown = activeDiffPopoverId.value === entryId;
const show = force ?? (activeDiffPopoverId.value !== entryId);
if (isShown !== show) {
activeDiffPopoverId.value = show ? activeDiffPopoverId.value : undefined;
activeDiffPopoverId.value = show ? entryId : undefined;
}
}
</script>
@ -99,7 +106,7 @@
<div v-if="isLoading" class="d-flex justify-content-center">
<div class="spinner-border"></div>
</div>
<table v-else class="table table-striped table-hover">
<table v-else class="table table-striped table-hover history-entries">
<thead>
<tr>
<th style="min-width: 12rem">Date</th>
@ -152,14 +159,14 @@
<td class="td-buttons">
<div class="d-grid">
<button
v-if="entry.labels.button"
v-if="entry.labels.revert"
type="button"
class="btn btn-secondary"
:disabled="!!isReverting"
@click="revert(entry)"
>
<div v-if="isReverting === entry" class="spinner-border spinner-border-sm"></div>
{{entry.labels.button}}
{{entry.labels.revert.button}}
</button>
</div>
</td>
@ -178,4 +185,11 @@
overflow: auto;
}
}
.fm-history {
.history-entries > tbody > tr {
// Make sure that lines without button have the same height as lines with button
height: calc(/* button line-height */ 1.5rem + /* button padding */ 2 * 0.375rem + /* button border */ 2 * 1px + /* td padding */ 2 * 0.5rem + /* td border-bottom */ 1px);
}
}
</style>

Wyświetl plik

@ -22,52 +22,62 @@ function existsNow(client: ClientContext, entry: HistoryEntry) {
export interface HistoryEntryLabels {
description: string;
button: string;
confirm: string;
revert?: {
title: string;
message: string;
button: string;
okLabel: string;
},
diff?: Array<ObjectDiffItem>;
}
export function getLabelsForHistoryEntry(client: ClientContext, entry: HistoryEntry): HistoryEntryLabels {
if(entry.type == "Pad") {
return {
description: "Changed pad settings",
button: "Revert",
confirm: "Do you really want to restore the old version of the pad settings?",
...(entry.objectBefore && entry.objectAfter ? { diff: getObjectDiff(entry.objectBefore, entry.objectAfter) } : {})
description: "Changed map settings",
revert: {
title: "Revert map settings",
message: "Do you really want to restore the old version of the map settings?",
button: "Revert",
okLabel: "Revert"
},
...(entry.objectBefore && entry.objectAfter ? { diff: getObjectDiff(entry.objectBefore, entry.objectAfter) } : {}),
};
}
const nameStrBefore = entry.objectBefore && entry.objectBefore.name ? "“" + entry.objectBefore.name + "”" : "";
const nameStrAfter = entry.objectAfter && entry.objectAfter.name ? "“" + entry.objectAfter.name + "”" : "";
const nameStrBefore = entry.objectBefore && entry.objectBefore.name ? `${entry.objectBefore.name}` : "";
const nameStrAfter = entry.objectAfter && entry.objectAfter.name ? `${entry.objectAfter.name}` : "";
const exists = existsNow(client, entry);
const ret: Partial<HistoryEntryLabels> = {
description: {
create: "Created",
update: "Changed",
delete: "Deleted"
}[entry.action] + " " + entry.type + " " + entry.objectId + " " + (nameStrBefore && nameStrAfter && nameStrBefore != nameStrAfter ? nameStrBefore + " (new name: " + nameStrAfter + ")" : (nameStrBefore || nameStrAfter)),
};
if(entry.action == "create") {
if(exists) {
ret.button = "Revert (delete)";
ret.confirm = "delete";
const descriptionAction = { create: "Created", update: "Changed", delete: "Deleted" }[entry.action];
const descriptionName = (nameStrBefore && nameStrAfter && nameStrBefore != nameStrAfter ? `${nameStrBefore} (new name: ${nameStrAfter})` : (nameStrBefore || nameStrAfter));
const diff = entry.objectBefore && entry.objectAfter ? getObjectDiff(entry.objectBefore, entry.objectAfter) : undefined;
const revert = (
entry.action === "create" ? (
exists ? {
title: `Delete ${entry.type}`,
button: "Revert (delete)",
message: "delete",
okLabel: "Delete"
} : undefined
) : exists ? {
title: `Revert ${entry.type}`,
button: "Revert",
message: "restore the old version of",
okLabel: "Revert"
} : {
title: `Restore ${entry.type}`,
button: "Restore",
message: "restore",
okLabel: "Restore"
}
} else if(exists) {
ret.button = "Revert";
ret.confirm = "restore the old version of";
} else {
ret.button = "Restore";
ret.confirm = "restore";
}
);
if(ret.confirm)
ret.confirm = "Do you really want to " + ret.confirm + " " + ((nameStrBefore || nameStrAfter) ? "the " + entry.type + " " + (nameStrBefore || nameStrAfter) : "this " + entry.type);
if(entry.objectBefore && entry.objectAfter)
ret.diff = getObjectDiff(entry.objectBefore, entry.objectAfter);
return ret as HistoryEntryLabels;
return {
description: `${descriptionAction} ${entry.type} ${entry.objectId} ${descriptionName}`,
revert: revert && {
...revert,
message: `Do you really want to ${revert.message} ${((nameStrBefore || nameStrAfter) ? `the ${entry.type} ${(nameStrBefore || nameStrAfter)}` : `this ${entry.type}`)}?`
},
...(diff ? { diff } : {})
};
}

Wyświetl plik

@ -5,10 +5,11 @@
import { Util } from "leaflet";
import FileResults from "./file-results.vue";
import SearchBoxTab from "./search-box/search-box-tab.vue";
import { computed, markRaw, ref, shallowReactive } from "vue";
import { computed, markRaw, readonly, ref, shallowReactive, toRef } from "vue";
import { useDomEventListener, useEventListener } from "../utils/utils";
import { useToasts } from "./ui/toasts/toasts.vue";
import { injectContextRequired, requireMapContext, requireSearchBoxContext } from "./facil-map-context-provider/facil-map-context-provider.vue";
import type { WritableImportTabContext } from "./facil-map-context-provider/import-tab-context";
const context = injectContextRequired();
const mapContext = requireMapContext(context);
@ -20,7 +21,6 @@
const files = ref<Array<FileResultObject & { title: string }>>([]);
const layers = shallowReactive<SearchResultsLayer[]>([]);
useEventListener(mapContext, "import-file", handleImportFile);
useEventListener(mapContext, "open-selection", handleOpenSelection);
useDomEventListener(mapContext.value.components.container, "dragenter", handleMapDragEnter);
@ -29,14 +29,18 @@
const layerIds = computed(() => layers.map((layer) => Util.stamp(layer)));
function handleImportFile(): void {
fileInputRef.value?.click();
}
const importTabContext = ref<WritableImportTabContext>({
openFilePicker() {
fileInputRef.value?.click();
}
});
context.provideComponent("importTab", toRef(readonly(importTabContext)));
function handleOpenSelection(): void {
for (let i = 0; i < layerIds.value.length; i++) {
if (mapContext.value.selection.some((item) => item.type == "searchResult" && item.layerId == layerIds.value[i])) {
searchBoxContext.value.activateTab(`fm${context.id}-import-tab-${i}`);
searchBoxContext.value.activateTab(`fm${context.id}-import-tab-${i}`, { expand: true });
break;
}
}
@ -93,7 +97,7 @@
files.value.push(result);
layers.push(layer);
setTimeout(() => {
searchBoxContext.value.activateTab(`fm${context.id}-import-tab-${files.value.length - 1}`);
searchBoxContext.value.activateTab(`fm${context.id}-import-tab-${files.value.length - 1}`, { expand: true });
}, 0);
}
} catch (err) {

Wyświetl plik

@ -16,6 +16,7 @@ import type { MapComponents, MapContextData, MapContextEvents, WritableMapContex
import type { ClientContext } from "../facil-map-context-provider/client-context";
import type { FacilMapContext } from "../facil-map-context-provider/facil-map-context";
import { requireClientContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import { sleep } from "facilmap-utils";
type MapContextWithoutComponents = Optional<WritableMapContext, 'components'>;
@ -128,7 +129,7 @@ function createSearchResultsLayer(map: Map): SearchResultsLayer {
return new SearchResultsLayer(undefined, { pathOptions: { weight: 7 } }).addTo(map);
}
function createSelectionHandler(map: Map, mapContext: MapContextWithoutComponents, markersLayer: MarkersLayer, linesLayer: LinesLayer, searchResultsLayer: SearchResultsLayer, overpassLayer: OverpassLayer): SelectionHandler {
function createSelectionHandler(map: Map, context: FacilMapContext, mapContext: MapContextWithoutComponents, markersLayer: MarkersLayer, linesLayer: LinesLayer, searchResultsLayer: SearchResultsLayer, overpassLayer: OverpassLayer): SelectionHandler {
const selectionHandler = new SelectionHandler(map, markersLayer, linesLayer, searchResultsLayer, overpassLayer).enable()
selectionHandler.on("fmChangeSelection", (event: any) => {
@ -143,7 +144,7 @@ function createSelectionHandler(map: Map, mapContext: MapContextWithoutComponent
});
selectionHandler.on("fmLongClick", (event: any) => {
mapContext.emit("map-long-click", { point: { lat: event.latlng.lat, lon: event.latlng.lng } });
context.components.clickMarkerTab?.openClickMarker({ lat: event.latlng.lat, lon: event.latlng.lng });
});
onScopeDispose(() => {
@ -157,16 +158,19 @@ function createHashHandler(map: Map, client: ClientContext, context: FacilMapCon
const hashHandler = new HashHandler(map, client, { overpassLayer, simulate: !context.settings.updateHash })
.on("fmQueryChange", async (e: any) => {
let smooth = true;
let autofocus = false;
if (!mapContext.components) {
// This is called while the hash handler is being enabled, so it is the initial view
smooth = false;
await nextTick();
autofocus = context.settings.autofocus;
await sleep(0); // Wait for components to be initialized (needed by openSpecialQuery())
}
const searchFormTab = context.components.searchFormTab;
if (!e.query)
mapContext.emit("search-set-query", { query: "", zoom: false, smooth: false });
searchFormTab?.setQuery("", false, false);
else if (!await openSpecialQuery(e.query, context, e.zoom, smooth))
mapContext.emit("search-set-query", { query: e.query, zoom: e.zoom, smooth });
searchFormTab?.setQuery(e.query, e.zoom, smooth, autofocus);
})
.enable();
@ -193,7 +197,7 @@ function createMapComponents(context: FacilMapContext, mapContext: MapContextWit
const mousePosition = createMousePosition(map);
const overpassLayer = createOverpassLayer(map, mapContext);
const searchResultsLayer = createSearchResultsLayer(map);
const selectionHandler = createSelectionHandler(map, mapContext, markersLayer, linesLayer, searchResultsLayer, overpassLayer);
const selectionHandler = createSelectionHandler(map, context, mapContext, markersLayer, linesLayer, searchResultsLayer, overpassLayer);
const hashHandler = createHashHandler(map, client, context, mapContext, overpassLayer);
const mapComponents: MapComponents = {

Wyświetl plik

@ -9,7 +9,7 @@
import "leaflet-mouse-position/src/L.Control.MousePosition.css";
import { createMapContext } from "./leaflet-map-components";
import vTooltip from "../../utils/tooltip";
import type { MapContext } from "../facil-map-context-provider/map-context";
import type { WritableMapContext } from "../facil-map-context-provider/map-context";
import { injectContextRequired, requireClientContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
const context = injectContextRequired();
@ -25,7 +25,7 @@
return `${location.origin}${location.pathname}${mapContext.value?.hash ? `#${mapContext.value.hash}` : ''}`;
});
const mapContext = ref<MapContext>();
const mapContext = ref<WritableMapContext>();
onMounted(async () => {
try {

Wyświetl plik

@ -39,7 +39,7 @@ import { normalizeLineName } from "facilmap-utils";
function handleOpenSelection(): void {
if (line.value)
searchBoxContext.value.activateTab(`fm${context.id}-line-info-tab`)
searchBoxContext.value.activateTab(`fm${context.id}-line-info-tab`, { expand: true })
}
function close(): void {

Wyświetl plik

@ -46,7 +46,12 @@
async function deleteLine(): Promise<void> {
toasts.hideToast(`fm${context.id}-line-info-delete`);
if (!await showConfirm({ title: "Remove line", message: `Do you really want to remove the line “${normalizeLineName(line.value.name)}”?` }))
if (!await showConfirm({
title: "Delete line",
message: `Do you really want to delete the line “${normalizeLineName(line.value.name)}”?`,
variant: "danger",
okLabel: "Delete"
}))
return;
isDeleting.value = true;
@ -85,19 +90,6 @@
mapContext.value.components.linesLayer.hideLine(line.value.id);
toasts.showToast(`fm${context.id}-line-info-move`, `Edit waypoints`, "Use the routing form or drag the line around to change it. Click “Finish” to save the changes.", {
actions: [
{ label: "Finish", onClick: () => { done(true); }},
{ label: "Cancel", onClick: () => { done(false); } }
]
});
isMoving.value = true;
await new Promise((resolve) => {
setTimeout(resolve);
});
const done = async (save: boolean) => {
const route = client.value.routes[routeId];
if (save && !route)
@ -122,6 +114,16 @@
mapContext.value.components.linesLayer.unhideLine(line.value.id);
}
};
toasts.showToast(`fm${context.id}-line-info-move`, `Edit waypoints`, "Use the routing form or drag the line around to change it. Click “Finish” to save the changes.", {
noCloseButton: true,
actions: [
{ label: "Finish", variant: "primary", onClick: () => { done(true); }},
{ label: "Cancel", onClick: () => { done(false); } }
]
});
isMoving.value = true;
} catch (err) {
toasts.showErrorToast(`fm${context.id}-line-info-move-error`, "Error saving line", err);
@ -232,11 +234,18 @@
:disabled="isDeleting || mapContext.interaction"
>
<div v-if="isDeleting" class="spinner-border spinner-border-sm"></div>
Remove
Delete
</button>
</div>
<RouteForm v-if="isMoving" active ref="routeForm" :route-id="`l${line.id}`" :show-toolbar="false"></RouteForm>
<RouteForm
v-if="isMoving"
active
ref="routeForm"
:routeId="`l${line.id}`"
:showToolbar="false"
noClear
></RouteForm>
<EditLineDialog
v-if="showEditDialog"

Wyświetl plik

@ -28,7 +28,8 @@
if (!await showConfirm({
title: "Delete type",
message: `Do you really want to delete the type “${type.name}”?`,
variant: "danger"
variant: "danger",
okLabel: "Delete"
})) {
return;
}
@ -45,7 +46,6 @@
<template>
<ModalDialog
title="Manage Types"
okOnly
:isBusy="isBusy"
size="lg"
class="fm-manage-types"
@ -64,23 +64,21 @@
<td>{{type.name}}</td>
<td>{{type.type}}</td>
<td class="td-buttons">
<div class="btn-toolbar">
<button
type="button"
class="btn btn-secondary"
:disabled="isDeleting[type.id]"
@click="editDialogTypeId = type.id"
>Edit</button>
<button
type="button"
@click="deleteType(type)"
class="btn btn-secondary"
:disabled="isDeleting[type.id]"
>
<div v-if="isDeleting[type.id]" class="spinner-border spinner-border-sm"></div>
Delete
</button>
</div>
<button
type="button"
class="btn btn-secondary"
:disabled="isDeleting[type.id]"
@click="editDialogTypeId = type.id"
>Edit</button>
<button
type="button"
@click="deleteType(type)"
class="btn btn-secondary"
:disabled="isDeleting[type.id]"
>
<div v-if="isDeleting[type.id]" class="spinner-border spinner-border-sm"></div>
Delete
</button>
</td>
</tr>
</tbody>
@ -97,6 +95,10 @@
</tfoot>
</table>
<EditTypeDialog v-if="editDialogTypeId !== undefined" :typeId="editDialogTypeId ?? undefined"></EditTypeDialog>
<EditTypeDialog
v-if="editDialogTypeId !== undefined"
:typeId="editDialogTypeId ?? undefined"
@hidden="editDialogTypeId = undefined"
></EditTypeDialog>
</ModalDialog>
</template>

Wyświetl plik

@ -44,7 +44,12 @@
toasts.hideToast(`fm${context.id}-save-view-error-${view.id}`);
try {
if (!await showConfirm({ title: "Delete view", message: `Do you really want to delete the view “${view.name}”?` }))
if (!await showConfirm({
title: "Delete view",
message: `Do you really want to delete the view “${view.name}”?`,
variant: "danger",
okLabel: "Delete"
}))
return;
isDeleting.value.add(view.id);
@ -61,7 +66,6 @@
<template>
<ModalDialog
title="Manage Views"
okOnly
:isBusy="isBusy"
size="lg"
class="fm-manage-views"

Wyświetl plik

@ -29,7 +29,7 @@
function handleOpenSelection(): void {
if (marker.value)
searchBoxContext.value.activateTab(`fm${context.id}-marker-info-tab`);
searchBoxContext.value.activateTab(`fm${context.id}-marker-info-tab`, { expand: true });
}
function close(): void {

Wyświetl plik

@ -12,7 +12,7 @@
import UseAsDropdown from "../ui/use-as-dropdown.vue";
import ZoomToObjectButton from "../ui/zoom-to-object-button.vue";
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import type { RouteDestination } from "../facil-map-context-provider/map-context";
import type { RouteDestination } from "../facil-map-context-provider/route-form-tab-context";
const context = injectContextRequired();
const client = requireClientContext(context);
@ -42,7 +42,12 @@
async function deleteMarker(): Promise<void> {
toasts.hideToast(`fm${context.id}-marker-info-delete`);
if (!await showConfirm({ title: "Remove marker", message: `Do you really want to remove the marker “${normalizeMarkerName(marker.value.name)}”?` }))
if (!await showConfirm({
title: "Delete marker",
message: `Do you really want to delete the marker “${normalizeMarkerName(marker.value.name)}”?`,
variant: "danger",
okLabel: "Delete"
}))
return;
isDeleting.value = true;
@ -124,7 +129,7 @@
:disabled="isDeleting || mapContext.interaction"
>
<div v-if="isDeleting" class="spinner-border spinner-border-sm"></div>
Remove
Delete
</button>
</div>

Wyświetl plik

@ -27,7 +27,7 @@
function handleOpenSelection(): void {
if (objects.value)
searchBoxContext.value.activateTab(`fm${context.id}-multiple-info-tab`);
searchBoxContext.value.activateTab(`fm${context.id}-multiple-info-tab`, { expand: true });
}
const title = computed(() => objects.value ? `${objects.value.length} objects` : "");

Wyświetl plik

@ -11,6 +11,8 @@
import { useCarousel } from "../../utils/carousel";
import ZoomToObjectButton from "../ui/zoom-to-object-button.vue";
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import vTooltip from "../../utils/tooltip";
import { normalizeLineName, normalizeMarkerName } from "facilmap-utils";
const context = injectContextRequired();
const client = requireClientContext(context);
@ -64,7 +66,12 @@
async function deleteObjects(): Promise<void> {
toasts.hideToast(`fm${context.id}-multiple-info-delete`);
if (!props.objects || !await showConfirm({ title: "Delete objects", message: `Do you really want to remove ${props.objects.length} objects?` }))
if (!props.objects || !await showConfirm({
title: "Delete objects",
message: `Do you really want to remove ${props.objects.length} objects?`,
variant: "danger",
okLabel: "Delete"
}))
return;
isDeleting.value = true;
@ -97,12 +104,12 @@
<template>
<div class="fm-multiple-info">
<div class="carousel slide" ref="carouselRef">
<div class="carousel slide fm-flex-carousel" ref="carouselRef">
<div class="carousel-item" :class="{ active: carousel.tab === 0 }">
<ul class="list-group">
<ul class="list-group fm-search-box-collapse-point">
<li v-for="object in props.objects" :key="`${isMarker(object) ? 'm' : 'l'}-${object.id}`" class="list-group-item active">
<span>
<a href="javascript:" @click="emit('click-object', object, $event)">{{object.name}}</a>
<a href="javascript:" @click="emit('click-object', object, $event)">{{isMarker(object) ? normalizeMarkerName(object.name) : normalizeLineName(object.name)}}</a>
{{" "}}
<span class="result-type" v-if="client.types[object.typeId]">({{client.types[object.typeId].name}})</span>
</span>
@ -111,7 +118,7 @@
</li>
</ul>
<div class="btn-toolbar">
<div class="btn-toolbar mt-2">
<ZoomToObjectButton
v-if="zoomDestination"
label="selection"
@ -127,7 +134,7 @@
:disabled="isDeleting || mapContext.interaction"
>
<div v-if="isDeleting" class="spinner-border spinner-border-sm"></div>
Remove
Delete
</button>
</div>
</div>
@ -152,6 +159,10 @@
<style lang="scss">
.fm-multiple-info {
display: flex;
flex-direction: column;
min-height: 0;
.list-group-item {
display: flex;
align-items: center;

Wyświetl plik

@ -92,11 +92,16 @@
<template>
<div class="fm-overpass-form">
<template v-if="!mapContext.overpassIsCustom">
<input class="form-control" type="search" v-model="searchTerm" placeholder="Filter…" autofocus />
<input
class="form-control fm-autofocus"
type="search"
v-model="searchTerm"
placeholder="Filter…"
/>
<hr />
<template v-if="searchTerm">
<div class="checkbox-grid">
<div class="checkbox-grid fm-search-box-collapse-point">
<template v-for="preset in filteredPresets" :key="preset.key">
<div class="form-check">
<input
@ -135,25 +140,29 @@
</template>
</ul>
<template v-for="(presets, idx) in categories[activeTab].presets" :key="idx">
<hr />
<div class="checkbox-grid">
<template v-for="preset in presets" :key="preset.key">
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
:id="`fm${context.id}-overpass-form-preset-${preset.key}`"
:checked="preset.isChecked"
@change="togglePreset(preset.key, ($event.target as HTMLInputElement).checked)"
/>
<label :for="`fm${context.id}-overpass-form-preset-${preset.key}`" class="form-check-label">
{{preset.label}}
</label>
</div>
</template>
</div>
</template>
<hr />
<div class="fm-search-box-collapse-point">
<template v-for="(presets, idx) in categories[activeTab].presets" :key="idx">
<hr v-if="idx > 0" />
<div class="checkbox-grid">
<template v-for="preset in presets" :key="preset.key">
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
:id="`fm${context.id}-overpass-form-preset-${preset.key}`"
:checked="preset.isChecked"
@change="togglePreset(preset.key, ($event.target as HTMLInputElement).checked)"
/>
<label :for="`fm${context.id}-overpass-form-preset-${preset.key}`" class="form-check-label">
{{preset.label}}
</label>
</div>
</template>
</div>
</template>
</div>
</template>
</template>
<template v-else>
@ -200,6 +209,7 @@
.fm-overpass-form {
display: flex;
flex-direction: column;
min-height: 0;
.checkbox-grid {
column-width: 160px;

Wyświetl plik

@ -18,7 +18,7 @@
function handleOpenSelection(): void {
if (elements.value.length > 0)
searchBoxContext.value.activateTab(`fm${context.id}-overpass-info-tab`);
searchBoxContext.value.activateTab(`fm${context.id}-overpass-info-tab`, { expand: true });
}
function handleElementClick(element: OverpassElement, event: MouseEvent): void {

Wyświetl plik

@ -8,7 +8,7 @@
import type { Type } from "facilmap-types";
import UseAsDropdown from "../ui/use-as-dropdown.vue";
import ZoomToObjectButton from "../ui/zoom-to-object-button.vue";
import type { RouteDestination } from "../facil-map-context-provider/map-context";
import type { RouteDestination } from "../facil-map-context-provider/route-form-tab-context";
import { overpassElementsToMarkersWithTags } from "../../utils/add";
import AddToMapDropdown from "../ui/add-to-map-dropdown.vue";
@ -63,6 +63,7 @@
<AddToMapDropdown
:markers="markersWithTags"
size="sm"
isSingle
></AddToMapDropdown>
<UseAsDropdown

Wyświetl plik

@ -75,7 +75,8 @@
if (!await showConfirm({
title: "Delete map",
message: `Are you sure you want to delete the map “${padData.value.name}”? Deleted maps cannot be restored!`,
variant: "danger"
variant: "danger",
okLabel: "Delete map"
})) {
return;
}

Wyświetl plik

@ -2,33 +2,39 @@
import RouteForm from "./route-form.vue";
import type { HashQuery } from "facilmap-leaflet";
import SearchBoxTab from "../search-box/search-box-tab.vue";
import { ref } from "vue";
import { useEventListener } from "../../utils/utils";
import { injectContextRequired, requireMapContext, requireSearchBoxContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import { readonly, ref, toRef } from "vue";
import { injectContextRequired, requireSearchBoxContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import type { WritableRouteFormTabContext } from "../facil-map-context-provider/route-form-tab-context";
const context = injectContextRequired();
const mapContext = requireMapContext(context);
const searchBoxContext = requireSearchBoxContext(context);
const routeForm = ref<InstanceType<typeof RouteForm>>();
const hashQuery = ref<HashQuery>();
useEventListener(mapContext, "route-set-query", (data) => {
routeForm.value!.setQuery(data);
});
useEventListener(mapContext, "route-set-from", (data) => {
routeForm.value!.setFrom(data);
});
useEventListener(mapContext, "route-add-via", (data) => {
routeForm.value!.addVia(data);
});
useEventListener(mapContext, "route-set-to", (data) => {
routeForm.value!.setTo(data);
const routeFormTabContext = ref<WritableRouteFormTabContext>({
setQuery(query, zoom, smooth) {
routeForm.value!.setQuery(query, zoom, smooth);
},
setFrom(destination) {
routeForm.value!.setFrom(destination);
},
addVia(destination) {
routeForm.value!.addVia(destination);
},
setTo(destination) {
routeForm.value!.setTo(destination);
}
});
context.provideComponent("routeFormTab", toRef(readonly(routeFormTabContext)));
function activate(): void {
searchBoxContext.value.activateTab(`fm${context.id}-route-form-tab`);
searchBoxContext.value.activateTab(`fm${context.id}-route-form-tab`, { expand: true });
}
</script>

Wyświetl plik

@ -19,7 +19,7 @@
import vTooltip from "../../utils/tooltip";
import DropdownMenu from "../ui/dropdown-menu.vue";
import ZoomToObjectButton from "../ui/zoom-to-object-button.vue";
import type { RouteDestination } from "../facil-map-context-provider/map-context";
import type { RouteDestination } from "../facil-map-context-provider/route-form-tab-context";
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import AddToMapDropdown from "../ui/add-to-map-dropdown.vue";
@ -83,9 +83,11 @@
const submitButton = ref<HTMLButtonElement>();
const props = withDefaults(defineProps<{
/** If false, the route layer will be opaque and not draggable. */
active?: boolean;
routeId?: string;
showToolbar?: boolean;
noClear?: boolean;
}>(), {
active: true,
showToolbar: true
@ -206,6 +208,10 @@
return undefined;
});
const destinationsMeta = computed(() => destinations.value.map((destination) => ({
isInvalid: getValidationState(destination) === false
})));
watchEffect(() => {
if (hasRoute.value)
routeLayer.setStyle({ opacity: props.active ? 1 : 0.35, raised: props.active });
@ -502,7 +508,7 @@
}
}
function setQuery({ query, zoom = true, smooth = true }: { query: string; zoom?: boolean; smooth?: boolean }): void {
function setQuery(query: string, zoom = true, smooth = true): void {
clear();
const split = splitRouteQuery(query);
destinations.value = split.queries.map((query) => ({ query }));
@ -546,7 +552,6 @@
class="input-group"
@mouseenter="destinationMouseOver(idx)"
@mouseleave="destinationMouseOut(idx)"
:state="getValidationState(destination)"
>
<span class="input-group-text px-2">
<a href="javascript:" class="fm-drag-handle" @contextmenu.prevent>
@ -558,7 +563,8 @@
v-model="destination.query"
:placeholder="idx == 0 ? 'From' : idx == destinations.length-1 ? 'To' : 'Via'"
:tabindex="idx+1"
:state="getValidationState(destination)"
:class="{ 'is-invalid': destinationsMeta[idx].isInvalid }"
:autofocus="idx === 0"
@blur="loadSuggestions(destination)"
/>
<template v-if="destination.query.trim() != ''">
@ -651,7 +657,7 @@
ref="submitButton"
>Go!</button>
<button
v-if="hasRoute"
v-if="hasRoute && !props.noClear"
type="button"
class="btn btn-secondary"
:tabindex="destinations.length+8"
@ -694,6 +700,7 @@
<AddToMapDropdown
:lines="linesWithTags"
size="sm"
isSingle
></AddToMapDropdown>
<DropdownMenu
@ -742,6 +749,10 @@
border-radius: 0.25rem;
}
.destination:first-child {
margin-top: calc(-0.5rem + 2px); // Offset space of first fm-route-form-hover-insert
}
hr.fm-route-form-hover-insert {
margin: 0.1rem -0.5rem;
width: auto;

Wyświetl plik

@ -177,7 +177,7 @@
<div class="row mb-3">
<label :for="`${id}-filter-input`" class="col-sm-3 col-form-label">Filter</label>
<div class="col-sm-9">
<input class="form-control" :id="`${id}-filter-input`" value="—" plaintext />
<input class="form-control-plaintext" :id="`${id}-filter-input`" value="—" />
</div>
</div>
</template>

Wyświetl plik

@ -4,7 +4,6 @@
import hammer from "hammerjs";
import { type Ref, defineComponent, nextTick, onMounted, onScopeDispose, reactive, readonly, ref, toRef, watch } from "vue";
import vTooltip, { hideAllTooltips } from "../../utils/tooltip";
import { useEventListener } from "../../utils/utils";
import type { SearchBoxEventMap, SearchBoxTab, WritableSearchBoxContext } from "../facil-map-context-provider/search-box-context";
import mitt from "mitt";
import { injectContextRequired, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
@ -26,7 +25,7 @@
}, { immediate: true });
if (activeTabId.value == null) {
activateTab(id);
activateTab(id, { autofocus: context.settings.autofocus });
}
onScopeDispose(() => {
@ -36,17 +35,28 @@
tabHistory.value = tabHistory.value.filter((v) => v !== id);
if (isActive) {
activeTabId.value = tabHistory.value[tabHistory.value.length - 1];
if (restoreHeight.value && context.isNarrow && containerRef.value) {
containerRef.value.style.flexBasis = `${restoreHeight.value}px`;
}
restoreHeight.value = undefined;
}
});
}
function activateTab(id: string, expand?: boolean) {
function activateTab(id: string, { expand = false, autofocus = false }: { expand?: boolean; autofocus?: boolean } = {}) {
activeTabId.value = id;
tabHistory.value.push(id);
restoreHeight.value = undefined;
if (expand) {
nextTick(() => {
searchBoxContext.emit("expand");
doExpand();
});
}
if (autofocus && !context.isNarrow) {
nextTick(() => {
containerRef.value?.querySelector<HTMLElement>(":scope > .card-body.active [autofocus],:scope > .card-body.active .fm-autofocus")?.focus();
});
}
}
@ -81,8 +91,6 @@
const hasFocus = ref(false);
const isResizing = ref(false);
useEventListener(toRef(searchBoxContext), "expand", handleExpand);
onMounted(() => {
const pan = new hammer.Manager(cardHeaderRef.value!);
pan.add(new hammer.Pan({ direction: hammer.DIRECTION_VERTICAL }));
@ -120,7 +128,7 @@
return Math.max(0, Math.min(maxHeight, height));
}
function handleExpand(): void {
function doExpand(): void {
if (context.isNarrow) {
const currentHeight = parseInt(getComputedStyle(containerRef.value!).flexBasis);
if (currentHeight < 120) {
@ -210,7 +218,7 @@
:aria-current="tabId === searchBoxContext.activeTabId ? 'true' : undefined"
href="javascript:"
:class="{ active: tabId === searchBoxContext.activeTabId }"
@click="searchBoxContext.activateTab(tabId, true)"
@click="searchBoxContext.activateTab(tabId, { expand: true, autofocus: true })"
>{{tab.title}}</a>
<a
@ -223,7 +231,7 @@
</div>
<template v-for="[tabId, tab] in searchBoxContext.tabs" :key="tabId">
<div v-show="tabId === searchBoxContext.activeTabId" class="card-body" :class="tab.class">
<div v-show="tabId === searchBoxContext.activeTabId" class="card-body" :class="[tab.class, { active: tabId === searchBoxContext.activeTabId }]">
<TabContent :tabId="tabId" :isActive="tabId === searchBoxContext.activeTabId"></TabContent>
</div>
</template>
@ -301,6 +309,8 @@
}
> .card-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
}
@ -347,6 +357,7 @@
hr {
width: 100%;
margin: 0.5rem 0;
}
.list-group-item {

Wyświetl plik

@ -2,10 +2,11 @@
import SearchForm from "./search-form.vue";
import { Util } from "leaflet";
import type { HashQuery } from "facilmap-leaflet";
import { ref } from "vue";
import { readonly, ref, toRef } from "vue";
import { useEventListener } from "../../utils/utils";
import SearchBoxTab from "../search-box/search-box-tab.vue";
import { injectContextRequired, requireMapContext, requireSearchBoxContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import type { WritableSearchFormTabContext } from "../facil-map-context-provider/search-form-tab-context";
const context = injectContextRequired();
const mapContext = requireMapContext(context);
@ -16,19 +17,22 @@
const hashQuery = ref<HashQuery | undefined>(undefined);
useEventListener(mapContext, "open-selection", handleOpenSelection);
useEventListener(mapContext, "search-set-query", handleSetQuery);
function handleOpenSelection(): void {
const layerId = Util.stamp(mapContext.value.components.searchResultsLayer);
if (mapContext.value.selection.some((item) => item.type == "searchResult" && item.layerId == layerId))
searchBoxContext.value.activateTab(`fm${context.id}-search-form-tab`);
searchBoxContext.value.activateTab(`fm${context.id}-search-form-tab`, { expand: true });
}
function handleSetQuery({ query, zoom = false, smooth = true }: { query: string; zoom?: boolean; smooth?: boolean }): void {
searchForm.value!.setSearchString(query);
searchForm.value!.search(zoom, undefined, smooth);
searchBoxContext.value.activateTab(`fm${context.id}-search-form-tab`, !!query);
}
const searchFormTabContext: WritableSearchFormTabContext = {
setQuery(query, zoom = false, smooth = true, autofocus = false): void {
searchForm.value!.setSearchString(query);
searchForm.value!.search(zoom, undefined, smooth);
searchBoxContext.value.activateTab(`fm${context.id}-search-form-tab`, { expand: !!query, autofocus });
}
};
context.provideComponent("searchFormTab", toRef(readonly(searchFormTabContext)));
</script>
<template>

Wyświetl plik

@ -25,7 +25,6 @@
const toasts = useToasts();
const autofocus = ref(!context.isNarrow && context.settings.autofocus);
const layerId = Util.stamp(mapContext.value.components.searchResultsLayer);
const searchInput = ref<HTMLInputElement>();
@ -173,7 +172,7 @@
<div class="fm-search-form">
<form action="javascript:" @submit.prevent="handleSubmit()">
<div class="input-group">
<input type="search" class="form-control" v-model="searchString" :autofocus="autofocus" ref="searchInput" />
<input type="search" class="form-control fm-autofocus" v-model="searchString" ref="searchInput" />
<button
type="submit"
class="btn btn-secondary"
@ -218,7 +217,6 @@
:auto-zoom="storage.autoZoom"
:union-zoom="storage.zoomToAll"
:layer-id="layerId"
class="fm-search-box-collapse-point"
/>
<SearchResults
v-else-if="searchResults || mapResults"
@ -227,7 +225,6 @@
:auto-zoom="storage.autoZoom"
:union-zoom="storage.zoomToAll"
:layer-id="layerId"
class="fm-search-box-collapse-point"
></SearchResults>
</div>
</template>

Wyświetl plik

@ -10,7 +10,7 @@
import { computed } from "vue";
import UseAsDropdown from "./ui/use-as-dropdown.vue";
import ZoomToObjectButton from "./ui/zoom-to-object-button.vue";
import type { RouteDestination } from "./facil-map-context-provider/map-context";
import type { RouteDestination } from "./facil-map-context-provider/route-form-tab-context";
import AddToMapDropdown from "./ui/add-to-map-dropdown.vue";
const props = withDefaults(defineProps<{
@ -89,27 +89,26 @@
</template>
</dl>
<div>
<div class="btn-toolbar" role="group">
<ZoomToObjectButton
v-if="zoomDestination"
label="search result"
size="sm"
:destination="zoomDestination"
></ZoomToObjectButton>
<div class="btn-toolbar">
<ZoomToObjectButton
v-if="zoomDestination"
label="search result"
size="sm"
:destination="zoomDestination"
></ZoomToObjectButton>
<AddToMapDropdown
:markers="markersWithTags"
:lines="linesWithTags"
size="sm"
></AddToMapDropdown>
<AddToMapDropdown
:markers="markersWithTags"
:lines="linesWithTags"
size="sm"
isSingle
></AddToMapDropdown>
<UseAsDropdown
v-if="isMarker && routeDestination"
size="sm"
:destination="routeDestination"
></UseAsDropdown>
</div>
<UseAsDropdown
v-if="isMarker && routeDestination"
size="sm"
:destination="routeDestination"
></UseAsDropdown>
</div>
</div>
</template>

Wyświetl plik

@ -14,6 +14,7 @@
import { useCarousel } from "../../utils/carousel";
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import AddToMapDropdown from "../ui/add-to-map-dropdown.vue";
import { normalizeLineName, normalizeMarkerName } from "facilmap-utils";
const context = injectContextRequired();
const client = requireClientContext(context);
@ -124,7 +125,7 @@
if (isMapResult(result)) {
if (result.kind == "marker" && !client.value.markers[result.id])
await client.value.getMarker({ id: result.id });
searchBoxContext.value?.activateTab(`fm${context.id}-${result.kind}-info-tab`, false);
searchBoxContext.value?.activateTab(`fm${context.id}-${result.kind}-info-tab`);
} else
carousel.setTab(1);
}, 0);
@ -158,61 +159,63 @@
<template>
<div class="fm-search-results" :class="{ isNarrow: context.isNarrow }">
<div class="carousel slide" ref="carouselRef">
<div class="carousel slide fm-flex-carousel" ref="carouselRef">
<div class="carousel-item" :class="{ active: carousel.tab === 0 }">
<div
v-if="(!searchResults || searchResults.length == 0) && (!mapResults || mapResults.length == 0)"
class="alert alert-danger"
>
No results have been found.
<div class="fm-search-box-collapse-point">
<div
v-if="(!searchResults || searchResults.length == 0) && (!mapResults || mapResults.length == 0)"
class="alert alert-danger"
>
No results have been found.
</div>
<slot name="before"></slot>
<ul v-if="mapResults && mapResults.length > 0" class="list-group">
<!-- eslint-disable-next-line vue/require-v-for-key -->
<li
v-for="result in mapResults"
class="list-group-item"
:class="{ active: activeResults.includes(result) }"
v-scroll-into-view="activeResults.includes(result)"
>
<span>
<a href="javascript:" @click="handleClick(result, $event)">{{isMarkerResult(result) ? normalizeMarkerName(result.name) : normalizeLineName(result.name)}}</a>
{{" "}}
<span class="result-type">({{client.types[result.typeId].name}})</span>
</span>
<a v-if="showZoom" href="javascript:" @click="zoomToResult(result)" v-tooltip.hover.left="'Zoom to result'"><Icon icon="zoom-in" alt="Zoom"></Icon></a>
<a href="javascript:" @click="handleOpen(result, $event)" v-tooltip.left="'Show details'"><Icon icon="arrow-right" alt="Details"></Icon></a>
</li>
</ul>
<hr v-if="mapResults && mapResults.length > 0 && searchResults && searchResults.length > 0"/>
<ul v-if="searchResults && searchResults.length > 0" class="list-group">
<!-- eslint-disable-next-line vue/require-v-for-key -->
<li
v-for="result in searchResults"
class="list-group-item"
:class="{ active: activeResults.includes(result) }"
v-scroll-into-view="activeResults.includes(result)"
>
<span>
<a href="javascript:" @click="handleClick(result, $event)">{{result.display_name}}</a>
{{" "}}
<span class="result-type" v-if="result.type">({{result.type}})</span>
</span>
<a v-if="showZoom" href="javascript:" @click="zoomToResult(result)" v-tooltip.left="'Zoom to result'"><Icon icon="zoom-in" alt="Zoom"></Icon></a>
<a href="javascript:" @click="handleOpen(result, $event)" v-tooltip.right="'Show details'"><Icon icon="arrow-right" alt="Details"></Icon></a>
</li>
</ul>
<slot name="after"></slot>
</div>
<slot name="before"></slot>
<ul v-if="mapResults && mapResults.length > 0" class="list-group">
<!-- eslint-disable-next-line vue/require-v-for-key -->
<li
v-for="result in mapResults"
class="list-group-item"
:class="{ active: activeResults.includes(result) }"
v-scroll-into-view="activeResults.includes(result)"
>
<span>
<a href="javascript:" @click="handleClick(result, $event)">{{result.name}}</a>
{{" "}}
<span class="result-type">({{client.types[result.typeId].name}})</span>
</span>
<a v-if="showZoom" href="javascript:" @click="zoomToResult(result)" v-tooltip.hover.left="'Zoom to result'"><Icon icon="zoom-in" alt="Zoom"></Icon></a>
<a href="javascript:" @click="handleOpen(result, $event)" v-tooltip.left="'Show details'"><Icon icon="arrow-right" alt="Details"></Icon></a>
</li>
</ul>
<hr v-if="mapResults && mapResults.length > 0 && searchResults && searchResults.length > 0"/>
<ul v-if="searchResults && searchResults.length > 0" class="list-group">
<!-- eslint-disable-next-line vue/require-v-for-key -->
<li
v-for="result in searchResults"
class="list-group-item"
:class="{ active: activeResults.includes(result) }"
v-scroll-into-view="activeResults.includes(result)"
>
<span>
<a href="javascript:" @click="handleClick(result, $event)">{{result.display_name}}</a>
{{" "}}
<span class="result-type" v-if="result.type">({{result.type}})</span>
</span>
<a v-if="showZoom" href="javascript:" @click="zoomToResult(result)" v-tooltip.left="'Zoom to result'"><Icon icon="zoom-in" alt="Zoom"></Icon></a>
<a href="javascript:" @click="handleOpen(result, $event)" v-tooltip.right="'Show details'"><Icon icon="arrow-right" alt="Details"></Icon></a>
</li>
</ul>
<slot name="after"></slot>
<div v-if="client.padData && !client.readonly && searchResults && searchResults.length > 0" class="btn-toolbar">
<div v-if="client.padData && !client.readonly && searchResults && searchResults.length > 0" class="btn-toolbar mt-2">
<button
type="button"
class="btn btn-secondary"
class="btn btn-secondary btn-sm"
:class="{ active: isAllSelected }"
@click="toggleSelectAll"
>Select all</button>
@ -221,6 +224,7 @@
:label="`Add selected item${activeSearchResults.length == 1 ? '' : 's'} to map`"
:markers="activeMarkersWithTags"
:lines="activeLinesWithTags"
size="sm"
>
<template v-if="hasCustomTypes" #after>
<li><hr class="dropdown-divider"></li>
@ -259,6 +263,10 @@
<style lang="scss">
.fm-search-results.fm-search-results {
display: flex;
flex-direction: column;
min-height: 0;
.list-group-item {
display: flex;
align-items: center;

Wyświetl plik

@ -7,6 +7,7 @@
import { useToasts } from "../ui/toasts/toasts.vue";
import DropdownMenu from "../ui/dropdown-menu.vue";
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import { sleep } from "facilmap-utils";
const emit = defineEmits<{
"hide-sidebar": [];
@ -21,19 +22,13 @@
| "manage-types"
>();
function addObject(type: Type): void {
if(type.type == "marker")
addMarker(type);
else if(type.type == "line")
addLine(type);
}
function addMarker(type: Type): void {
drawMarker(type, context, toasts);
}
function addLine(type: Type): void {
drawLine(type, context, toasts);
async function addObject(type: Type): Promise<void> {
if(type.type == "marker") {
await sleep(0); // For some reason this is necessary for the dropdown to close itself
drawMarker(type, context, toasts);
} else if(type.type == "line") {
drawLine(type, context, toasts);
}
}
</script>

Wyświetl plik

@ -1,9 +1,9 @@
<script setup lang="ts">
import PadSettingsDialog from "../pad-settings-dialog/pad-settings-dialog.vue";
import EditFilter from "../edit-filter-dialog.vue";
import EditFilterDialog from "../edit-filter-dialog.vue";
import HistoryDialog from "../history-dialog/history-dialog.vue";
import ShareDialog from "../share-dialog.vue";
import { computed, ref } from "vue";
import { computed, ref, toRef } from "vue";
import vTooltip from "../../utils/tooltip";
import DropdownMenu from "../ui/dropdown-menu.vue";
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
@ -11,6 +11,7 @@
const context = injectContextRequired();
const client = requireClientContext(context);
const mapContext = requireMapContext(context);
const importTabContext = toRef(() => context.components.importTab);
const props = defineProps<{
interactive: boolean;
@ -38,10 +39,6 @@
return { q: "", a: "" };
}
});
function importFile(): void {
mapContext.value.emit("import-file");
}
</script>
<template>
@ -61,11 +58,11 @@
>Share</a>
</li>
<li v-if="props.interactive">
<li v-if="props.interactive && importTabContext">
<a
class="dropdown-item"
href="javascript:"
@click="importFile(); emit('hide-sidebar')"
@click="importTabContext.openFilePicker(); emit('hide-sidebar')"
>Open file</a>
</li>
@ -143,10 +140,10 @@
@hidden="dialog = undefined"
></ShareDialog>
<EditFilter
<EditFilterDialog
v-if="dialog === 'edit-filter' && client.padData"
@hidden="dialog = undefined"
></EditFilter>
></EditFilterDialog>
<HistoryDialog
v-if="dialog === 'history' && client.padData"

Wyświetl plik

@ -6,6 +6,7 @@
import type { SelectedItem } from '../../utils/selection';
import { type LineWithTags, type MarkerWithTags, addToMap } from '../../utils/add';
import type { ButtonSize } from '../../utils/bootstrap';
import DropdownMenu from "./dropdown-menu.vue";
const context = injectContextRequired();
const client = requireClientContext(context);
@ -17,6 +18,8 @@
lines?: LineWithTags[];
label?: string;
size?: ButtonSize;
/** If true, the markers/lines entries are assumed to refer to a single object, omitting the prefix "Marker/line/polgon items as" */
isSingle?: boolean;
}>(), {
label: "Add to map"
})
@ -82,7 +85,7 @@
href="javascript:"
class="dropdown-item"
@click="addMarkers(type)"
>{{props.lines ? 'Marker items as ' : ''}}{{type.name}}</a>
>{{!props.isSingle && props.lines ? 'Marker items as ' : ''}}{{type.name}}</a>
</li>
</template>
</template>
@ -93,7 +96,7 @@
href="javascript:"
class="dropdown-item"
@click="addLines(type)"
>{{props.markers ? 'Line/polygon items as ' : ''}}{{type.name}}</a>
>{{!props.isSingle && props.markers ? 'Line/polygon items as ' : ''}}{{type.name}}</a>
</li>
</template>
</template>

Wyświetl plik

@ -2,8 +2,8 @@
import { computed, createApp, defineComponent, h, ref, type VNode, type VNodeArrayChildren, withDirectives } from "vue";
import Alert from "./alert.vue";
import vValidity from "./validated-form/validity";
import { useModal } from "../../utils/modal";
import type { ThemeColour } from "../../utils/bootstrap";
import ModalDialog from "./modal-dialog.vue";
export type AlertProps = {
title: string;
@ -11,6 +11,8 @@
type?: "alert" | "confirm";
variant?: ThemeColour;
show?: boolean;
okLabel?: string;
okFocus?: boolean;
};
export interface AlertResult {
@ -45,16 +47,16 @@
});
}
export async function showAlert(props: Omit<AlertProps, 'type' | 'show'>): Promise<void> {
await renderAlert({ ...props, type: 'alert' });
export async function showAlert(props: Omit<AlertProps, 'type' | 'show' | 'okFocus'>): Promise<void> {
await renderAlert({ ...props, okFocus: true, type: 'alert' });
}
export async function showConfirm(props: Omit<AlertProps, 'type' | 'show'>): Promise<boolean> {
const result = await renderAlert({ ...props, type: 'confirm' });
export async function showConfirm(props: Omit<AlertProps, 'type' | 'show' | 'okFocus'>): Promise<boolean> {
const result = await renderAlert({ ...props, okFocus: true, type: 'confirm' });
return result.ok;
}
export async function showPrompt({ initialValue = "", validate, ...props }: Omit<AlertProps, 'type' | 'show' | 'message'> & {
export async function showPrompt({ initialValue = "", validate, ...props }: Omit<AlertProps, 'type' | 'show' | 'message' | 'okFocus'> & {
initialValue?: string;
/** Validate the value. Return an empty string or undefined to indicate validity. */
validate?: (value: string) => string | undefined;
@ -68,6 +70,7 @@
const result = await renderAlert({
...props,
message: '',
okFocus: false,
type: 'confirm',
getContent: () => h('div', {
class: touched.value ? 'was-validated' : ''
@ -75,7 +78,7 @@
withDirectives(
h('input', {
type: "text",
class: `form-control${touched.value ? ' was-validated' : ''}`,
class: "form-control",
value: value.value,
onInput: (e: InputEvent) => {
value.value = (e.target as HTMLInputElement).value;
@ -107,7 +110,9 @@
<script setup lang="ts">
const props = withDefaults(defineProps<AlertProps>(), {
type: "alert"
type: "alert",
okLabel: "OK",
okFocus: true
});
const emit = defineEmits<{
@ -120,49 +125,27 @@
ok: false
});
const modalRef = ref<HTMLElement>();
const modal = useModal(modalRef, {
onShown: () => {
emit('shown');
},
onHide: () => {
emit('hide', result.value);
},
onHidden: () => {
emit('hidden', result.value);
}
});
const modalRef = ref<InstanceType<typeof ModalDialog>>();
const formRef = ref<HTMLFormElement>();
const formTouched = ref(false);
const handleSubmit = () => {
if (formRef.value!.checkValidity()) {
result.value.ok = true;
modal.hide();
} else {
formTouched.value = true;
}
result.value.ok = true;
modalRef.value!.modal.hide();
};
</script>
<template>
<Teleport to="body">
<div class="modal fade" tabindex="-1" aria-hidden="true" ref="modalRef">
<div class="modal-dialog">
<form class="modal-content" :class="{ 'was-validated': formTouched }" @submit.prevent="handleSubmit()" novalidate ref="formRef">
<div class="modal-header">
<h1 class="modal-title fs-5">{{props.title}}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<slot>{{props.message}}</slot>
</div>
<div class="modal-footer">
<button v-if="type === 'confirm'" type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn" :class="`btn-${props.variant ?? 'primary'}`">OK</button>
</div>
</form>
</div>
</div>
</Teleport>
<ModalDialog
:title="props.title"
:isCreate="props.type === 'confirm'"
:okLabel="props.okLabel"
:okVariant="props.variant"
:okFocus="props.okFocus"
@shown="emit('shown')"
@hide="emit('hide', result)"
@hidden="emit('hidden', result)"
@submit="handleSubmit"
ref="modalRef"
>
<slot>{{props.message}}</slot>
</ModalDialog>
</template>

Wyświetl plik

@ -15,7 +15,7 @@
}
function validateColour(colour: string): string | undefined {
if (!isValidColour(colour)) {
if (colour && !isValidColour(colour)) {
return "Needs to be in 3-digit or 6-digit hex format, for example f00 or 0000ff.";
}
}
@ -105,7 +105,12 @@
<Hue :value="val" @change="handleChange"></Hue>
<ul ref="gridRef">
<li v-for="colour in colours" :key="colour" :class="{ active: value == colour }">
<a href="javascript:" :style="{ backgroundColor: `#${colour}` }" @click="emit('update:modelValue', colour)"></a>
<a
href="javascript:"
:style="{ backgroundColor: `#${colour}` }"
@click="emit('update:modelValue', colour)"
tabindex="-1"
></a>
</li>
</ul>
</div>

Wyświetl plik

@ -61,6 +61,8 @@
}
watch(() => buttonRef.value?.elementRef, (newRef, oldRef, onCleanup) => {
onCleanup(() => {}); // TODO: Delete me https://github.com/vuejs/core/issues/5151#issuecomment-1515613484
if (newRef) {
dropdownRef.value = new CustomDropdown(newRef, {
popperConfig: (defaultConfig) => ({
@ -138,6 +140,12 @@
}
}
watchEffect(() => {
if (props.isDisabled || props.isBusy) {
dropdownRef.value?.hide();
}
});
</script>
<template>

Wyświetl plik

@ -18,12 +18,17 @@
customClass?: string;
/** If true, the width of the popover will be fixed to the width of the element. */
enforceElementWidth?: boolean;
/** If true, a click of the reference element will not toggle the popover. */
ignoreClick?: boolean;
}>(), {
show: undefined
});
const emit = defineEmits<{
"update:show": [show: boolean];
shown: [];
hide: [];
hidden: [];
}>();
const show = useRefWithOverride(false, () => props.show, (show) => {
@ -35,7 +40,7 @@
const shouldUseModal = useMaxBreakpoint("xs");
watch(show, () => {
if (!show) {
if (!show.value) {
showModal.value = false;
showPopover.value = false;
} else if (!showModal.value && !showPopover.value) {
@ -51,9 +56,16 @@
const modalRef = ref<HTMLElement>();
const modal = useModal(modalRef, {
onShown: () => {
emit("shown");
},
onHide: () => {
emit("hide");
},
onHidden: () => {
showModal.value = false;
show.value = false;
emit("hidden");
}
});
@ -63,7 +75,9 @@
};
const handleClick = () => {
show.value = !show.value;
if (!props.ignoreClick) {
show.value = !show.value;
}
};
const close = () => {
@ -72,7 +86,7 @@
</script>
<template>
<div class="bb-popover">
<div class="fm-hybrid-popover">
<div ref="trigger" @click="handleClick()">
<slot name="trigger"></slot>
</div>
@ -84,6 +98,9 @@
:class="props.customClass"
hideOnOutsideClick
:enforceElementWidth="props.enforceElementWidth"
@shown="emit('shown')"
@hide="emit('hide')"
@hidden="emit('hidden')"
>
<template v-slot:header>
{{props.title}}

Wyświetl plik

@ -3,6 +3,7 @@
import { useModal } from "../../utils/modal";
import ValidatedForm, { type CustomSubmitEvent } from "./validated-form/validated-form.vue";
import { reactiveReadonlyView } from "../../utils/vue";
import type { ThemeColour } from "../../utils/bootstrap";
const props = withDefaults(defineProps<{
title?: string;
@ -12,31 +13,62 @@
isBusy?: boolean;
/** If true, the Save button will always be shown (also if isModified is false). */
isCreate?: boolean;
/** If false, the Save button will be hidden (unless noCancel is true). */
/** If false, a Close button will be shown instead of a Save button (except is isCreate is true). */
isModified?: boolean;
size?: "sm" | "default" | "lg" | "xl";
/**
* If specified, the dialog will be shrunk a bit to make the underlying dialog partly visible in case of stacked dialogs.
* 0 represents a non-stacked dialog (default), 1 should be the first stacked dialog, ...
*/
stackLevel?: number;
okLabel?: string;
okVariant?: ThemeColour;
/** If true, the OK button is focused when the dialog is opened. */
okFocus?: boolean;
}>(), {
isModified: false,
size: "lg"
});
const emit = defineEmits<{
shown: [];
hide: [];
hidden: [];
submit: [event: CustomSubmitEvent];
}>();
const validatedFormRef = ref<InstanceType<typeof ValidatedForm>>();
const isSubmitting = computed(() => validatedFormRef.value?.formData.isSubmitting);
const submitRef = ref<HTMLElement>();
const modalRef = ref<HTMLElement>();
const modal = useModal(modalRef, {
emit,
onShown: () => {
if (props.okFocus) {
submitRef.value?.focus();
} else {
modalRef.value?.querySelector<HTMLElement>("[autofocus],.fm-autofocus")?.focus();
}
emit("shown");
},
onHide: () => {
emit("hide");
},
onHidden: () => {
emit("hidden");
},
static: computed(() => isSubmitting.value || props.isBusy || props.noCancel || props.isModified)
});
const isCloseButton = computed(() => !props.isCreate && !props.isModified);
function handleSubmit(event: CustomSubmitEvent) {
emit("submit", event);
if (isCloseButton.value) {
modal.hide();
} else {
emit("submit", event);
}
}
const expose = reactiveReadonlyView(() => ({
@ -60,8 +92,13 @@
:data-bs-backdrop="isSubmitting || props.isBusy || props.noCancel || props.isModified ? 'static' : 'true'"
:data-bs-keyboard="isSubmitting || props.isBusy || props.noCancel || props.isModified ? 'false' : 'true'"
>
<div class="modal-dialog modal-dialog-scrollable">
<ValidatedForm class="modal-content" @submit="handleSubmit" ref="validatedFormRef">
<div class="modal-dialog modal-dialog-scrollable" :style="props.stackLevel ? { padding: `${20*props.stackLevel}px ${40*props.stackLevel}px` } : undefined">
<ValidatedForm
class="modal-content"
@submit="handleSubmit"
ref="validatedFormRef"
:noValidate="isCloseButton"
>
<div class="modal-header">
<h1 class="modal-title fs-5">{{props.title}}</h1>
<button
@ -82,20 +119,20 @@
<div style="flex-grow: 1"></div>
<button
v-if="!props.noCancel"
v-if="!props.noCancel && !isCloseButton"
type="button"
class="btn btn-secondary"
:class="props.isModified || props.isCreate ? 'btn-secondary' : 'btn-primary'"
@click="modal.hide()"
:disabled="isSubmitting || props.isBusy"
>{{props.isModified || props.isCreate ? 'Cancel' : 'Close'}}</button>
>Cancel</button>
<button
v-if="props.noCancel || props.isModified || props.isCreate"
type="submit"
class="btn btn-primary"
:class="props.okVariant && `btn-${props.okVariant}`"
:disabled="isSubmitting || props.isBusy"
>{{props.okLabel ?? 'Save'}}</button>
ref="submitRef"
>{{props.okLabel ?? (isCloseButton ? 'Close' : 'Save')}}</button>
</div>
</ValidatedForm>
</div>

Wyświetl plik

@ -1,7 +1,8 @@
<script setup lang="ts">
import { type StyleValue, computed, ref, watchEffect } from "vue";
import { type StyleValue, computed, ref } from "vue";
import HybridPopover from "./hybrid-popover.vue";
import vValidity, { vValidityContext } from "./validated-form/validity";
import { useDomEventListener, useNonClickFocusHandler, useNonDragClickHandler } from "../../utils/utils";
const props = withDefaults(defineProps<{
id?: string;
@ -20,6 +21,7 @@
const emit = defineEmits<{
keydown: [event: KeyboardEvent];
"update:modelValue": [value: string];
focusPopover: [];
}>();
const value = computed({
@ -29,49 +31,77 @@
}
});
const focusFilterOnNextOpen = ref(false);
const isOpen = ref(false);
const containerRef = ref<HTMLElement>();
const inputRef = ref<HTMLInputElement>();
watchEffect((onCleanup) => {
useDomEventListener(document.body, "keydown", handleBodyKeyDownCapture, { capture: true });
useDomEventListener(document.body, "keydown", handleBodyKeyDown, { capture: true });
function handleBodyKeyDownCapture(e: Event): void {
if (isOpen.value) {
document.body.addEventListener("keydown", handleBodyKeyDown);
onCleanup(() => {
document.body.addEventListener("keydown", handleBodyKeyDown);
});
const event = e as KeyboardEvent;
if (event.key == "Enter") {
inputRef.value?.focus();
isOpen.value = false;
event.preventDefault();
event.stopPropagation();
} else if (event.key == "Escape") {
isOpen.value = false;
event.preventDefault();
event.stopPropagation();
inputRef.value?.focus();
}
}
});
}
function handleBodyKeyDown(event: KeyboardEvent): void {
if (event.key == "Enter") {
inputRef.value?.focus();
isOpen.value = false;
event.preventDefault();
} else if (["ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight"].includes(event.key)) {
emit("keydown", event);
} else if (event.key == "Escape" && isOpen.value) {
isOpen.value = false;
event.preventDefault();
inputRef.value?.focus();
function handleBodyKeyDown(e: Event): void {
if (isOpen.value) {
emit("keydown", e as KeyboardEvent);
}
}
function handleInputKeyDown(event: KeyboardEvent): void {
if (["ArrowDown", "ArrowUp"].includes(event.key)) {
if (!isOpen.value) {
isOpen.value = true;
event.preventDefault();
} else
emit("keydown", event);
event.stopPropagation(); // To not be picked up by handleBodyKeyDown()
if (["ArrowDown", "ArrowUp"].includes(event.key) && !isOpen.value) {
focusFilterOnNextOpen.value = true;
isOpen.value = true;
event.preventDefault();
event.stopPropagation();
} else if (event.key == "Escape" && isOpen.value) {
event.preventDefault();
event.stopPropagation(); // Prevent closing outer modal
isOpen.value = false;
}
}
useNonClickFocusHandler(() => inputRef.value, open);
useNonDragClickHandler(() => inputRef.value, open);
function open() {
focusFilterOnNextOpen.value = true;
isOpen.value = true;
}
function handleInputDblClick() {
focusFilterOnNextOpen.value = false;
inputRef.value?.focus();
}
function handleToggleButtonClick() {
if (isOpen.value) {
isOpen.value = false;
} else {
open();
}
}
function handleShown() {
if (focusFilterOnNextOpen.value) {
emit("focusPopover");
}
}
</script>
<template>
@ -80,13 +110,16 @@
v-model:show="isOpen"
:enforceElementWidth="props.enforceElementWidth"
:customClass="props.customClass"
@shown="handleShown"
ignoreClick
>
<template #trigger>
<div class="input-group has-validation" v-validity-context>
<span
class="input-group-text"
@click="inputRef?.focus()"
:style="props.previewStyle">
:style="props.previewStyle"
>
<slot name="preview"></slot>
</span>
<input
@ -99,7 +132,15 @@
:id="id"
ref="inputRef"
@keydown="handleInputKeyDown"
@dblclick="handleInputDblClick"
/>
<button
type="button"
class="btn btn-secondary dropdown-toggle"
:class="{ active: isOpen }"
tabindex="-1"
@click="handleToggleButtonClick()"
></button>
<div class="invalid-feedback">
{{props.validationError}}
</div>
@ -110,35 +151,6 @@
<slot :is-modal="isModal" :close="close"></slot>
</template>
</HybridPopover>
<!-- <b-popover
:target="`${id}-input-group`"
:container="body"
triggers="manual"
placement="bottom"
:fallback-placement="[]"
:custom-class="`fm-picker-popover ${uniqueClass} ${customClass}`"
:delay="0"
boundary="viewport"
@show="handleOpenPopover"
@hidden="handleClosePopover"
:show.sync="popoverOpen"
>
<div @focusin.stop="handlePopoverFocus" class="fm-field-popover-content">
<slot :is-modal="false" :close="close"></slot>
</div>
</b-popover>
<b-modal
:id="`${id}-modal`"
v-model="modalOpen"
:body-class="`fm-picker-modal ${customClass}`"
ok-only
hide-header
scrollable
>
<slot :is-modal="true" :close="close"></slot>
</b-modal> -->
</div>
</template>

Wyświetl plik

@ -1,7 +1,8 @@
<script lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, ref, toRef, watch, watchEffect } from "vue";
import { Popover, Tooltip } from "bootstrap";
import { useResizeObserver } from "../../utils/vue";
import { useDomEventListener } from "../../utils/utils";
/**
* Like Bootstrap Popover, but uses an existing popover element rather than creating a new one. This way, the popover
@ -41,107 +42,91 @@
hideOnOutsideClick?: boolean;
/** If true, the width of the popover will be fixed to the width of the element. */
enforceElementWidth?: boolean;
placement?: Tooltip.PopoverPlacement
placement?: Tooltip.PopoverPlacement;
}>(), {
placement: "bottom"
});
const emit = defineEmits<{
"update:show": [show: boolean];
shown: [];
hide: [];
hidden: [];
}>();
const popoverContent = ref<HTMLElement | null>(null);
const popoverContent = ref<HTMLElement>();
const renderPopover = ref(false);
const show = async () => {
renderPopover.value = true;
await nextTick();
if (props.element) {
CustomPopover.getOrCreateInstance(props.element, {
watchEffect((onCleanup) => {
onCleanup(() => {}); // TODO: Delete me https://github.com/vuejs/core/issues/5151#issuecomment-1515613484
if (props.element && popoverContent.value) {
const popover = new CustomPopover(props.element, {
placement: props.placement,
content: popoverContent.value!,
content: popoverContent.value,
trigger: 'manual',
popperConfig: (defaultConfig) => ({
...defaultConfig,
strategy: "fixed"
})
}).show();
}
};
const hide = () => {
if (props.element) {
CustomPopover.getInstance(props.element)?.hide(); // Will be destroyed by hidden.bs.popover event listener
}
if (props.show) {
emit("update:show", false);
}
};
const handleHidden = () => {
if (props.element) {
CustomPopover.getInstance(props.element)?.dispose();
}
renderPopover.value = false;
};
watch([
() => props.element,
() => props.placement
], ([el], oldValues, onCleanup) => {
if (el) {
el.addEventListener("hidden.bs.popover", handleHidden);
if (props.show) {
show();
}
});
popover.show();
onCleanup(() => {
el.removeEventListener("hidden.bs.popover", handleHidden);
CustomPopover.getInstance(el)?.dispose();
popover.dispose();
});
}
}, { immediate: true });
watch(() => props.show, () => {
if (props.element) {
if (props.show) {
show();
} else {
hide();
}
}
}, { immediate: true });
const handleDocumentClick = (e: MouseEvent) => {
if (props.hideOnOutsideClick && e.target instanceof Node && !props.element?.contains(e.target) && !popoverContent.value?.contains(e.target)) {
hide();
}
};
onMounted(() => {
document.addEventListener('click', handleDocumentClick, { capture: true });
// TODO: Handle element blur (and focus?)
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick, { capture: true });
if (props.element) {
CustomPopover.getInstance(props.element)?.dispose()
useDomEventListener(toRef(() => props.element), "shown.bs.popover", () => {
emit("shown");
});
useDomEventListener(toRef(() => props.element), "hide.bs.popover", () => {
emit("hide");
});
useDomEventListener(toRef(() => props.element), "hidden.bs.popover", () => {
renderPopover.value = false;
emit("hidden");
});
watch(() => props.show, (show) => {
renderPopover.value = show;
});
useDomEventListener(() => props.element, "focusout", (e: Event) => {
const event = e as FocusEvent;
// relatedTarget == null: target is out of viewport (ignore to allow focussing dev tools)
if (event.relatedTarget && !popoverContent.value?.contains(event.relatedTarget as Node) && !props.element?.contains(event.relatedTarget as Node)) {
emit("update:show", false);
}
});
function handlePopoverFocusOut(event: FocusEvent) {
// relatedTarget == null: target is out of viewport (ignore to allow focussing dev tools)
if (event.relatedTarget && !popoverContent.value?.contains(event.relatedTarget as Node) && !props.element?.contains(event.relatedTarget as Node)) {
emit("update:show", false);
}
}
useDomEventListener(document, "click", (e: Event) => {
if (props.show && props.hideOnOutsideClick && e.target instanceof Node && !props.element?.contains(e.target) && !popoverContent.value?.contains(e.target)) {
emit("update:show", false);
}
}, { capture: true });
const elementSize = useResizeObserver(computed(() => props.enforceElementWidth ? props.element : undefined));
</script>
<template>
<div
v-if="renderPopover"
class="popover fade bs-popover-auto"
class="popover fm-popover fade bs-popover-auto"
ref="popoverContent"
:style="props.enforceElementWidth && elementSize ? { maxWidth: 'none', width: `${elementSize.contentRect.width}px` } : undefined"
@focusout="handlePopoverFocusOut"
:tabindex="-1 /* Allow focusing by click (for focusout event relatedTarget), do not allow focusing by tab */"
>
<div class="popover-arrow"></div>
<h3 v-if="$slots.header" class="popover-header">

Wyświetl plik

@ -5,6 +5,8 @@
const props = defineProps<{
value: string | undefined;
items: Record<string, string>;
/** If true, tabindex="-1" will be set on all elements. */
noFocus?: boolean;
}>();
const emit = defineEmits<{
@ -14,8 +16,8 @@
const containerRef = ref<HTMLElement>();
const code = computed(() => Object.entries(props.items).map(([key, val]) => (`
<li data-fm-item="${quoteHtml(key)}">
<a href="javascript:" class="dropdown-item">
<li class="list-group-item list-group-item-action" data-fm-item="${quoteHtml(key)}">
<a href="javascript:"${props.noFocus ? ` tabindex="-1"` : ""}>
${val}
</a>
</li>
@ -25,11 +27,13 @@
if (containerRef.value) {
for (const el of containerRef.value.querySelectorAll(".active")) {
el.classList.remove("active");
el.removeAttribute("aria-current");
}
const active = props.value != null && [...containerRef.value.querySelectorAll<HTMLElement>("[data-fm-item]")].find((el) => el.getAttribute("data-fm-item") === props.value);
if (active) {
active.querySelector("a")!.classList.add("active");
active.classList.add("active");
active.setAttribute("aria-current", "true");
}
}
});
@ -46,5 +50,23 @@
</script>
<template>
<ul ref="containerRef" v-show="Object.keys(items).length > 0" v-html="code" @click="handleClick"></ul>
</template>
<ul
ref="containerRef"
v-show="Object.keys(items).length > 0"
v-html="code"
@click="handleClick"
class="fm-prerendered-list list-group"
></ul>
</template>
<style lang="scss">
.fm-prerendered-list.fm-prerendered-list.fm-prerendered-list {
border-radius: unset;
li {
padding: 0;
border: none;
border-radius: unset;
}
}
</style>

Wyświetl plik

@ -48,12 +48,13 @@
close();
}
async function handleKeyDown(event: KeyboardEvent): Promise<void> {
function handleKeyDown(event: KeyboardEvent): void {
const newVal = arrowNavigation(Object.keys(items), props.modelValue, gridRef.value!.containerRef!, event);
if (newVal) {
emit("update:modelValue", newVal);
await nextTick();
gridRef.value?.containerRef?.querySelector<HTMLElement>(".active")?.focus();
nextTick(() => {
gridRef.value?.containerRef?.querySelector<HTMLElement>(".active > a")?.focus();
});
}
}
</script>
@ -65,7 +66,6 @@
customClass="fm-shape-field"
:validationError="validationError"
@keydown="handleKeyDown"
enforceElementWidth
>
<template #preview>
<span style="width: 1.4em"><img :src="valueSrc"></span>
@ -79,6 +79,7 @@
:value="modelValue ?? undefined"
@click="handleClick($event, close)"
ref="gridRef"
noFocus
></PrerenderedList>
</template>
</Picker>
@ -86,6 +87,8 @@
<style lang="scss">
.fm-shape-field {
max-width: none;
.popover-body {
display: flex;
flex-direction: column;
@ -100,7 +103,7 @@
padding: 0;
list-style-type: none;
display: grid;
grid-template-columns: repeat(5, 37px);
grid-template-columns: repeat(5, 40px);
overflow-y: auto;
li {
@ -113,7 +116,7 @@
justify-content: center;
flex-grow: 1;
color: inherit;
padding: 5px 8px;
padding: 5px;
}
}
}

Wyświetl plik

@ -23,6 +23,8 @@
});
watchEffect((onCleanup) => {
onCleanup(() => {}); // TODO: Delete me https://github.com/vuejs/core/issues/5151#issuecomment-1515613484
if (innerSidebarRef.value) {
const pan = new hammer.Manager(innerSidebarRef.value);
pan.add(new hammer.Pan({ direction: hammer.DIRECTION_RIGHT }));

Wyświetl plik

@ -32,6 +32,7 @@
});
const filter = ref("");
const filterRef = ref<HTMLElement>();
const items = computed(() => {
const result: Record<string, string> = {};
@ -51,7 +52,7 @@
const validationError = computed(() => {
if (props.validationError) {
return props.validationError;
} else if (props.modelValue && props.modelValue.length !== 1 && symbolList.includes(props.modelValue)) {
} else if (props.modelValue && props.modelValue.length !== 1 && !symbolList.includes(props.modelValue)) {
return "Unknown icon";
} else {
return undefined;
@ -66,11 +67,16 @@
async function handleKeyDown(event: KeyboardEvent): Promise<void> {
const newVal = arrowNavigation(Object.keys(items.value), props.modelValue, gridRef.value!.containerRef!, event);
if (newVal) {
filterRef.value?.focus();
emit("update:modelValue", newVal);
await nextTick();
gridRef.value?.containerRef?.querySelector<HTMLElement>(".active")?.focus();
}
}
function handleFocusPopover() {
filterRef.value?.focus();
}
</script>
<template>
@ -81,6 +87,7 @@
:validationError="validationError"
@keydown="handleKeyDown"
enforceElementWidth
@focusPopover="handleFocusPopover()"
>
<template #preview>
<Icon :icon="modelValue ?? undefined"></Icon>
@ -89,11 +96,12 @@
<template #default="{ close }">
<input
type="search"
class="form-control"
class="form-control fm-keyboard-navigation-exception"
v-model="filter"
placeholder="Filter"
autocomplete="off"
autofocus
ref="filterRef"
tabindex="-1"
/>
<div v-if="Object.keys(items).length == 0" class="alert alert-danger mt-2 mb-1">No icons could be found.</div>
@ -101,8 +109,9 @@
<PrerenderedList
:items="items"
:value="modelValue ?? undefined"
noFocus
@click="handleClick($event, close)"
ref="grid"
ref="gridRef"
></PrerenderedList>
</template>
</Picker>

Wyświetl plik

@ -20,6 +20,7 @@
variant?: ThemeColour;
noCloseButton?: boolean;
autoHide?: boolean;
onHide?: () => void;
onHidden?: () => void;
}
@ -27,6 +28,7 @@
onClick?: (e: MouseEvent) => void;
label: string;
href?: string;
variant?: ThemeColour;
}
interface ToastInstance extends ToastOptions {
@ -121,6 +123,10 @@
toastRef.addEventListener("shown.bs.toast", () => resolve());
Toast.getOrCreateInstance(toastRef, { autohide: !!toast.autoHide }).show();
toastRef.addEventListener("hide.bs.toast", () => {
toast.onHide?.();
});
toastRef.addEventListener("hidden.bs.toast", () => {
toasts.value = toasts.value.filter((t) => t !== toast);
toastRefs.delete(toast);
@ -173,19 +179,21 @@
{{toast.message}}
</div>
<div class="fm-toast-actions">
<div v-if="(toast.actions?.length ?? 0) > 0" class="btn-toolbar mt-2 pt-2 border-top">
<template v-for="(action, idx) in toast.actions" :key="idx">
<button
v-if="!action.href"
type="button"
class="btn btn-secondary btn-sm"
class="btn btn-sm"
:class="`btn-${action.variant ?? 'secondary'}`"
@click="action.onClick"
>{{action.label}}</button>
<a
v-if="action.href"
:href="action.href"
class="btn btn-secondary btn-sm"
class="btn btn-sm"
:class="`btn-${action.variant ?? 'secondary'}`"
@click="action.onClick"
>{{action.label}}</a>
</template>
@ -198,11 +206,5 @@
<style lang="scss">
.fm-toasts {
z-index: 10002;
.fm-toast-actions {
button + button {
margin-left: 5px;
}
}
}
</style>

Wyświetl plik

@ -1,13 +1,13 @@
<script setup lang="ts">
import { toRef } from "vue";
import type { ButtonSize } from "../../utils/bootstrap";
import { injectContextRequired, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import type { RouteDestination } from "../facil-map-context-provider/map-context";
import { injectContextRequired } from "../facil-map-context-provider/facil-map-context-provider.vue";
import type { RouteDestination } from "../facil-map-context-provider/route-form-tab-context";
import DropdownMenu from "./dropdown-menu.vue";
const context = injectContextRequired();
const mapContext = requireMapContext(context);
const searchBoxContext = toRef(() => context.components.searchBox);
const routeFormContext = toRef(() => context.components.routeFormTab);
const props = defineProps<{
destination: RouteDestination;
@ -15,15 +15,22 @@
size?: ButtonSize;
}>();
function useAs(event: "route-set-from" | "route-add-via" | "route-set-to"): void {
mapContext.value.emit(event, props.destination);
searchBoxContext.value!.activateTab(`fm${context.id}-route-form-tab`);
function useAs(type: "from" | "via" | "to"): void {
if (type === "from") {
routeFormContext.value!.setFrom(props.destination);
} else if (type === "via") {
routeFormContext.value!.addVia(props.destination);
} else if (type === "to") {
routeFormContext.value!.setTo(props.destination);
}
searchBoxContext.value!.activateTab(`fm${context.id}-route-form-tab`, { autofocus: true });
}
</script>
<template>
<DropdownMenu
v-if="searchBoxContext && context.settings.search"
v-if="searchBoxContext && routeFormContext && context.settings.search"
:size="props.size"
:isDisabled="props.isDisabled"
label="Use as"
@ -32,7 +39,7 @@
<a
href="javascript:"
class="dropdown-item"
@click="useAs('route-set-from')"
@click="useAs('from')"
>Route start</a>
</li>
@ -40,7 +47,7 @@
<a
href="javascript:"
class="dropdown-item"
@click="useAs('route-add-via')"
@click="useAs('via')"
>Route via</a>
</li>
@ -48,7 +55,7 @@
<a
href="javascript:"
class="dropdown-item"
@click="useAs('route-set-to')"
@click="useAs('to')"
>Route destination</a>
</li>
</DropdownMenu>

Wyświetl plik

@ -1,5 +1,5 @@
<script lang="ts">
import { type Ref, onScopeDispose, reactive, readonly, ref, watchEffect } from "vue";
import { type Ref, onScopeDispose, reactive, readonly, ref, watchEffect, toRef } from "vue";
import { type ExtendableEventMixin, extendableEventMixin } from "../../../utils/utils";
import { useToasts } from "../toasts/toasts.vue";
@ -16,7 +16,11 @@
const allForms = reactive(new Map<Ref<HTMLFormElement | undefined>, ValidatedFormData>());
export function useValidatedForm(formRef: Ref<HTMLFormElement | undefined>, onSubmit: (event: CustomSubmitEvent) => void): Readonly<ValidatedFormData> {
export function useValidatedForm(
formRef: Ref<HTMLFormElement | undefined>,
onSubmit: (event: CustomSubmitEvent) => void,
{ noValidate }: { noValidate?: Ref<boolean> } = {}
): Readonly<ValidatedFormData> {
const toasts = useToasts();
const validationPromises = new Map<Element, Promise<any>>();
const isValidating = reactive(new Map<Element, boolean>());
@ -29,16 +33,20 @@
data.isTouched = true;
data.isSubmitting = true;
await Promise.all(validationPromises.values());
try {
if (!noValidate?.value) {
await Promise.all(validationPromises.values());
if (formRef.value!.checkValidity()) {
try {
const event = { ...extendableEventMixin };
onSubmit(event);
await event._awaitPromises();
} finally {
data.isSubmitting = false;
if (!formRef.value!.checkValidity()) {
return;
}
}
const event = { ...extendableEventMixin };
onSubmit(event);
await event._awaitPromises();
} finally {
data.isSubmitting = false;
}
}),
setValidationPromise: (element, promise) => {
@ -83,6 +91,7 @@
const props = defineProps<{
action?: string;
target?: string;
noValidate?: boolean;
}>();
const emit = defineEmits<{
@ -92,6 +101,8 @@
const formRef = ref<HTMLFormElement>();
const formData = useValidatedForm(formRef, (event) => {
emit("submit", event);
}, {
noValidate: toRef(() => props.noValidate)
});
defineExpose({ formData });
@ -102,7 +113,6 @@
@submit.prevent="formData.submit()"
novalidate
ref="formRef"
:class="{ 'was-validated': formData.isTouched }"
:action="props.action"
:target="props.target ?? 'javascript:'"
>

Wyświetl plik

@ -69,8 +69,12 @@ const updateValidityContext: Directive<FormElement, void> = (el, binding) => {
el.classList.add("was-validated");
};
el.addEventListener("input", el._fmValidityInputListener);
el.addEventListener("blur", el._fmValidityInputListener);
el.addEventListener("focusout", el._fmValidityInputListener);
}
el.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("input,textarea,select")?.form?.addEventListener("submit", el._fmValidityInputListener);
if (el._fmValidityTouched) {
el.classList.add("was-validated");
}
@ -78,10 +82,18 @@ const updateValidityContext: Directive<FormElement, void> = (el, binding) => {
export const vValidityContext: Directive<FormElement, void> = {
mounted: updateValidityContext,
beforeUpdate: (el) => {
if (el._fmValidityInputListener) {
el.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("input,textarea,select")?.removeEventListener("submit", el._fmValidityInputListener); // Added again during updated
}
},
updated: updateValidityContext,
beforeUnmount: (el) => {
if (el._fmValidityInputListener) {
el.removeEventListener("input", el._fmValidityInputListener);
el.removeEventListener("blur", el._fmValidityInputListener);
el.removeEventListener("focusout", el._fmValidityInputListener);
el.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("input,textarea,select")?.removeEventListener("submit", el._fmValidityInputListener);
delete el._fmValidityInputListener;
}
}

Wyświetl plik

@ -54,7 +54,7 @@ export { default as SearchForm } from "./components/search-form/search-form.vue"
export { default as SearchResultInfo } from "./components/search-result-info.vue";
export { default as SearchResults } from "./components/search-results/search-results.vue";
export { default as Toolbox } from "./components/toolbox/toolbox.vue";
export { default as ColourField } from "./components/ui/colour-field.vue";
export { default as ColourPicker } from "./components/ui/colour-picker.vue";
export { default as ElevationPlot } from "./components/ui/elevation-plot.vue";
export { default as ElevationStats } from "./components/ui/elevation-stats.vue";
export { default as FieldInput } from "./components/ui/field-input.vue";
@ -63,10 +63,10 @@ export { default as Icon } from "./components/ui/icon.vue";
export { default as Picker } from "./components/ui/picker.vue";
export { default as PrerenderedList } from "./components/ui/prerendered-list.vue";
export { default as RouteMode } from "./components/ui/route-mode.vue";
export { default as ShapeField } from "./components/ui/shape-field.vue";
export { default as ShapePicker } from "./components/ui/shape-picker.vue";
export { default as Sidebar } from "./components/ui/sidebar.vue";
export { default as SizeField } from "./components/ui/size-field.vue";
export { default as SymbolField } from "./components/ui/symbol-field.vue";
export { default as SizePicker } from "./components/ui/size-picker.vue";
export { default as SymbolPicker } from "./components/ui/symbol-picker.vue";
export { default as Toast } from "./components/ui/toasts/toast.vue";
export * from "./components/ui/toasts/toasts.vue";
export { default as WidthField } from "./components/ui/width-field.vue";
export { default as WidthPicker } from "./components/ui/width-picker.vue";

Wyświetl plik

@ -27,4 +27,19 @@
.fm-form-range-with-label {
// Same padding-top as .col-form-label plus half line-height (1.5) minus half range input height (16px)
padding-top: calc(0.375rem + var(--bs-border-width) + 0.75rem - 8px);
}
/**
* Make a carousel use flex containers rather than floating block containers.
*/
.fm-flex-carousel {
display: flex;
min-height: 0;
> .carousel-item.active, > .carousel-item-next, > .carousel-item-prev {
display: flex;
flex-direction: column;
min-height: 0;
}
}

Wyświetl plik

@ -20,6 +20,8 @@ export function useCarousel(element: Ref<HTMLElement | undefined>): Readonly<Car
});
watch(element, (newRef, oldRef, onCleanup) => {
onCleanup(() => {}); // TODO: Delete me https://github.com/vuejs/core/issues/5151#issuecomment-1515613484
if (newRef) {
const carousel = new Carousel(newRef, {
interval: 0,

Wyświetl plik

@ -1,6 +1,5 @@
import { addClickListener } from "facilmap-leaflet";
import type { ID, Type } from "facilmap-types";
import { getUniqueId } from "./utils";
import type { ToastContext } from "../components/ui/toasts/toasts.vue";
import type { FacilMapContext } from "../components/facil-map-context-provider/facil-map-context";
import { requireClientContext, requireMapContext } from "../components/facil-map-context-provider/facil-map-context-provider.vue";
@ -20,19 +19,29 @@ export function drawMarker(type: Type, context: FacilMapContext, toasts: ToastCo
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "marker", id: marker.id }], true);
if (!mapContext.value.components.map.fmFilterFunc(marker, client.value.types[marker.typeId]))
toasts.showToast(getUniqueId("fm-draw-add-marker"), `${type.name} successfully added`, "The marker was successfully added, but the active filter is preventing it from being shown.", { variant: "success", noCloseButton: false });
if (!mapContext.value.components.map.fmFilterFunc(marker, client.value.types[marker.typeId])) {
toasts.showToast(
undefined,
`${type.name} successfully added`,
"The marker was successfully added, but the active filter is preventing it from being shown.",
{ variant: "success", autoHide: true }
);
}
} catch (err) {
toasts.showErrorToast("fm-draw-add-marker", "Error adding marker", err);
}
});
toasts.showToast("fm-draw-add-marker", `Add ${type.name}`, "Please click on the map to add a marker.", {
noCloseButton: true,
actions: [
{ label: "Cancel", onClick: () => {
toasts.hideToast("fm-draw-add-marker");
clickListener.cancel();
} }
{
label: "Cancel",
onClick: () => {
toasts.hideToast("fm-draw-add-marker");
clickListener.cancel();
}
}
]
});
}
@ -69,13 +78,21 @@ export function moveMarker(markerId: ID, context: FacilMapContext, toasts: Toast
}
toasts.showToast("fm-draw-drag-marker", "Drag marker", "Drag the marker to reposition it.", {
noCloseButton: true,
actions: [
{ label: "Save", onClick: () => {
finish(true);
}},
{ label: "Cancel", onClick: () => {
finish(false);
} }
{
label: "Save",
variant: "primary",
onClick: () => {
finish(true);
}
},
{
label: "Cancel",
onClick: () => {
finish(false);
}
}
]
});
@ -92,13 +109,21 @@ export async function drawLine(type: Type, context: FacilMapContext, toasts: Toa
const lineTemplate = await client.value.getLineTemplate({ typeId: type.id });
toasts.showToast("fm-draw-add-line", `Add ${type.name}`, "Click on the map to draw a line. Click “Finish” to save it.", {
noCloseButton: true,
actions: [
{ label: "Finish", onClick: () => {
mapContext.value.components.linesLayer.endDrawLine(true);
}},
{ label: "Cancel", onClick: () => {
mapContext.value.components.linesLayer.endDrawLine(false);
} }
{
label: "Finish",
variant: "primary",
onClick: () => {
mapContext.value.components.linesLayer.endDrawLine(true);
}
},
{
label: "Cancel",
onClick: () => {
mapContext.value.components.linesLayer.endDrawLine(false);
}
}
]
});
@ -110,8 +135,14 @@ export async function drawLine(type: Type, context: FacilMapContext, toasts: Toa
const line = await client.value.addLine({ typeId: type.id, routePoints });
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "line", id: line.id }], true);
if (!mapContext.value.components.map.fmFilterFunc(line, client.value.types[line.typeId]))
toasts.showToast(getUniqueId("fm-draw-add-line"), `${type.name} successfully added`, "The line was successfully added, but the active filter is preventing it from being shown.", { variant: "success", noCloseButton: false });
if (!mapContext.value.components.map.fmFilterFunc(line, client.value.types[line.typeId])) {
toasts.showToast(
undefined,
`${type.name} successfully added`,
"The line was successfully added, but the active filter is preventing it from being shown.",
{ variant: "success", autoHide: true }
);
}
}
} catch (err) {
toasts.showErrorToast("fm-draw-add-line", "Error adding line", err);

Wyświetl plik

@ -2,13 +2,6 @@ import { Modal } from "bootstrap";
import { type Ref, shallowRef, watch, watchEffect } from "vue";
export interface ModalConfig {
emit?: {
/**
* Emitted when the modal is closed and the fade-out animation has finished. Should cause the parent component to remove the
* modal from the tree.
*/
(type: 'hidden'): void;
};
/** Will be called when the fade-in animation has finished. */
onShown?: (event: Modal.Event) => void;
/** Will be called before the fade-out animation when the modal is closed. */
@ -26,7 +19,7 @@ export interface ModalActions {
/**
* Enables a Bootstrap modal dialog on the element that is saved in the returned {@link ModalActions#ref}.
*/
export function useModal(modalRef: Ref<HTMLElement | undefined>, { emit, onShown, onHide, static: isStatic }: ModalConfig): ModalActions {
export function useModal(modalRef: Ref<HTMLElement | undefined>, { onShown, onHide, onHidden, static: isStatic }: ModalConfig): ModalActions {
const modal = shallowRef<Modal>();
const handleShow = (e: Event) => {
@ -46,12 +39,12 @@ export function useModal(modalRef: Ref<HTMLElement | undefined>, { emit, onShown
};
const handleHidden = (e: Event) => {
if (emit) {
emit('hidden');
}
onHidden?.(e as Modal.Event);
};
watch(modalRef, (newRef, oldRef, onCleanup) => {
onCleanup(() => {}); // TODO: Delete me https://github.com/vuejs/core/issues/5151#issuecomment-1515613484
if (newRef) {
modal.value = new Modal(newRef);

Wyświetl plik

@ -19,7 +19,7 @@ export function getGridColumnsCount(grid: Element): number {
export function arrowNavigation<V>(values: V[], value: V | undefined, grid: Element, event: KeyboardEvent): V | undefined {
if (!["ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight"].includes(event.key) || event.shiftKey || event.ctrlKey || event.metaKey)
return;
if (["ArrowLeft", "ArrowRight"].includes(event.key) && (event.target as HTMLElement | undefined)?.closest("input"))
if (["ArrowLeft", "ArrowRight"].includes(event.key) && (event.target as HTMLElement | undefined)?.closest("input:not(.fm-keyboard-navigation-exception)"))
return;
event.preventDefault();

Wyświetl plik

@ -1,13 +1,15 @@
import { cloneDeep, isEqual } from "lodash-es";
import type { CRU, Field, Line, Marker, Type } from "facilmap-types";
import type { Emitter } from "mitt";
import { type DeepReadonly, type Ref, onBeforeUnmount, onMounted, watchEffect } from "vue";
import { type DeepReadonly, type Ref, watchEffect, toRef, effectScope } from "vue";
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// https://stackoverflow.com/a/62085569/242365
export type DistributedKeyOf<T> = T extends any ? keyof T : never;
export type AnyRef<T> = T | Ref<T> | (() => T);
/**
* Performs a 3-way merge. Takes the difference between oldObject and newObject and applies it to targetObject.
* @param oldObject {Object}
@ -60,10 +62,14 @@ export function isPromise(object: any): object is Promise<unknown> {
return typeof object === 'object' && 'then' in object && typeof object.then === 'function';
}
export function useEventListener<EventMap extends Record<string, unknown>, EventType extends keyof EventMap>(emitter: Ref<Emitter<EventMap> | DeepReadonly<Emitter<EventMap>> | undefined>, type: EventType, listener: (data: EventMap[EventType]) => void): void {
export function useEventListener<EventMap extends Record<string, unknown>, EventType extends keyof EventMap>(emitter: AnyRef<Emitter<EventMap> | DeepReadonly<Emitter<EventMap>> | undefined>, type: EventType, listener: (data: EventMap[EventType]) => void): void {
const emitterRef = toRef(emitter);
watchEffect((onCleanup) => {
if (emitter.value) {
const val = emitter.value;
onCleanup(() => {}); // TODO: Delete me https://github.com/vuejs/core/issues/5151#issuecomment-1515613484
if (emitterRef.value) {
const val = emitterRef.value;
val.on(type, listener);
onCleanup(() => {
val.off(type, listener);
@ -72,13 +78,18 @@ export function useEventListener<EventMap extends Record<string, unknown>, Event
});
}
export function useDomEventListener<Element extends EventTarget, Args extends Parameters<Element["addEventListener"]>>(element: EventTarget, ...args: Args): void {
onMounted(() => {
(element as any).addEventListener(...args);
});
export function useDomEventListener<Element extends EventTarget, Args extends Parameters<Element["addEventListener"]>>(element: AnyRef<EventTarget | undefined>, ...args: Args): void {
watchEffect((onCleanup) => {
onCleanup(() => {}); // TODO: Delete me https://github.com/vuejs/core/issues/5151#issuecomment-1515613484
onBeforeUnmount(() => {
(element as any).removeEventListener(...args);
const elementRef = toRef(element);
if (elementRef.value) {
const el = elementRef.value as any;
el.addEventListener(...args);
onCleanup(() => {
el.removeEventListener(...args);
});
}
});
}
@ -131,3 +142,63 @@ export function validateRequired(val: any): string | undefined {
return "Must not be empty.";
}
}
/**
* Registers a focus handler on the given element that does not fire when the focus was given through a click.
*/
export function useNonClickFocusHandler(element: AnyRef<HTMLElement | undefined>, onFocus: (e: FocusEvent) => void): void {
let lastEvent: {
timeout: ReturnType<typeof setTimeout>;
hadMouseDown?: boolean;
focusEvent?: FocusEvent;
} | undefined;
useDomEventListener(element, "mousedown", () => {
lastEvent = {
...lastEvent,
timeout: lastEvent?.timeout ?? setTimeout(handleTimeout, 0),
hadMouseDown: true
};
});
useDomEventListener(element, "focus", (e: Event) => {
lastEvent = {
...lastEvent,
timeout: lastEvent?.timeout ?? setTimeout(handleTimeout, 0),
focusEvent: e as FocusEvent
};
});
function handleTimeout() {
if (lastEvent?.focusEvent && !lastEvent.hadMouseDown) {
onFocus(lastEvent.focusEvent);
}
lastEvent = undefined;
}
}
/**
* Registers a click handler on the given element that does not fire when the click is caused by a drag.
*/
export function useNonDragClickHandler(element: AnyRef<HTMLElement | undefined>, onClick: (e: MouseEvent) => void): void {
let hasMoved = false;
useDomEventListener(element, "mousedown", () => {
hasMoved = false;
const scope = effectScope();
scope.run(() => {
useDomEventListener(document, "mousemove", () => {
hasMoved = true;
}, { capture: true });
useDomEventListener(document, "mouseup", () => {
scope.stop();
}, { capture: true });
});
});
useDomEventListener(element, "click", (e) => {
if (!hasMoved) {
onClick(e as MouseEvent);
}
});
}

Wyświetl plik

@ -59,6 +59,8 @@ export function useResizeObserver(
});
watch(element, (value, oldValue, onCleanup) => {
onCleanup(() => {}); // TODO: Delete me https://github.com/vuejs/core/issues/5151#issuecomment-1515613484
if (value) {
observer.observe(value);
onCleanup(() => {

Wyświetl plik

@ -138,10 +138,11 @@ export async function openSpecialQuery(query: string, context: FacilMapContext,
const mapContext = requireMapContext(context);
const client = requireClientContext(context);
const searchBoxContext = toRef(() => context.components.searchBox);
const routeFormTabContext = toRef(() => context.components.routeFormTab);
if(searchBoxContext.value && query.match(/ to /i)) {
mapContext.value.emit("route-set-query", { query, zoom, smooth });
searchBoxContext.value.activateTab(`fm${context.id}-route-form-tab`);
if(searchBoxContext.value && routeFormTabContext.value && query.match(/ to /i)) {
routeFormTabContext.value.setQuery(query, zoom, smooth);
searchBoxContext.value.activateTab(`fm${context.id}-route-form-tab`, { autofocus: true });
return true;
}

Wyświetl plik

@ -55,29 +55,29 @@
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^6.4.2",
"@types/cheerio": "^0.22.32",
"@types/geojson": "^7946.0.11",
"@types/highland": "^2.12.16",
"@types/leaflet": "^1.9.6",
"@types/leaflet.markercluster": "^1.5.2",
"@types/lodash-es": "^4.17.9",
"@types/node-fetch": "^2.6.6",
"@types/rollup-plugin-auto-external": "^2.0.3",
"@types/yauzl": "^2.10.1",
"@types/cheerio": "^0.22.34",
"@types/geojson": "^7946.0.13",
"@types/highland": "^2.12.18",
"@types/leaflet": "^1.9.8",
"@types/leaflet.markercluster": "^1.5.4",
"@types/lodash-es": "^4.17.11",
"@types/node-fetch": "^2.6.9",
"@types/rollup-plugin-auto-external": "^2.0.5",
"@types/yauzl": "^2.10.3",
"cheerio": "^1.0.0-rc.12",
"fast-glob": "^3.3.1",
"happy-dom": "^12.9.1",
"fast-glob": "^3.3.2",
"happy-dom": "^12.10.3",
"highland": "^2.13.5",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.5",
"rollup": "3",
"rollup-plugin-auto-external": "^2.0.0",
"svgo": "^3.0.2",
"svgo": "^3.0.3",
"ts-node": "^10.9.1",
"typescript": "^5.2.2",
"vite": "^4.4.11",
"vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0",
"vite-plugin-dts": "^3.6.0",
"vite-plugin-dts": "^3.6.3",
"vitest": "^0.34.6",
"yauzl": "^2.10.0"
},

Wyświetl plik

@ -15,11 +15,11 @@
"build": "yarn workspaces foreach -vt run build"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"eslint": "^8.50.0",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.53.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-vue": "^9.18.1"
},
"version": "0.0.0",

Wyświetl plik

@ -54,38 +54,38 @@
"facilmap-utils": "workspace:^",
"find-cache-dir": "^5.0.0",
"lodash-es": "^4.17.21",
"maxmind": "^4.3.15",
"maxmind": "^4.3.17",
"md5-file": "^5.0.0",
"mysql2": "^3.6.1",
"node-cron": "^3.0.2",
"mysql2": "^3.6.3",
"node-cron": "^3.0.3",
"p-throttle": "^5.1.0",
"sequelize": "^6.33.0",
"sequelize": "^6.34.0",
"socket.io": "^4.7.2",
"string-similarity": "^4.0.4",
"strip-bom-buf": "^3.0.1",
"strip-bom-buf": "^4.0.0",
"unzipper": "^0.10.14"
},
"devDependencies": {
"@digitak/esrun": "^3.2.25",
"@types/cheerio": "^0.22.32",
"@types/compression": "^1.7.3",
"@types/debug": "^4.1.9",
"@types/ejs": "^3.1.3",
"@types/express": "^4.17.18",
"@types/express-domain-middleware": "^0.0.7",
"@types/geojson": "^7946.0.11",
"@types/lodash-es": "^4.17.9",
"@types/node": "^20.8.2",
"@types/node-cron": "^3.0.9",
"@types/string-similarity": "^4.0.0",
"@types/cheerio": "^0.22.34",
"@types/compression": "^1.7.5",
"@types/debug": "^4.1.12",
"@types/ejs": "^3.1.5",
"@types/express": "^4.17.21",
"@types/express-domain-middleware": "^0.0.9",
"@types/geojson": "^7946.0.13",
"@types/lodash-es": "^4.17.11",
"@types/node": "^20.9.0",
"@types/node-cron": "^3.0.11",
"@types/string-similarity": "^4.0.2",
"cpy-cli": "^5.0.0",
"debug": "^4.3.4",
"rimraf": "^5.0.5",
"rollup-plugin-auto-external": "^2.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.2.2",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0",
"vite": "^4.5.0",
"vite-plugin-dts": "^3.6.3",
"vitest": "^0.34.6"
}
}

Wyświetl plik

@ -465,25 +465,25 @@ function _formatAddress(result: NominatimResult) {
}
async function _loadUrl(url: string, completeOsmObjects = false): Promise<string> {
let bodyBuf = await fetch(
let bodyBuf = new Uint8Array(await fetch(
url,
{
headers: {
"User-Agent": config.userAgent
}
}
).then(async (res) => Buffer.from(await res.arrayBuffer()));
).then<ArrayBuffer>(async (res) => await res.arrayBuffer()));
if(!bodyBuf)
throw new Error("Invalid response from server.");
if(bodyBuf[0] == 0x42 && bodyBuf[1] == 0x5a && bodyBuf[2] == 0x68) {// bzip2
bodyBuf = Buffer.from(compressjs.Bzip2.decompressFile(bodyBuf));
bodyBuf = Buffer.from(compressjs.Bzip2.decompressFile(Buffer.from(bodyBuf)));
}
else if(bodyBuf[0] == 0x1f && bodyBuf[1] == 0x8b && bodyBuf[2] == 0x08) // gzip
bodyBuf = await util.promisify(zlib.gunzip.bind(zlib))(bodyBuf);
const body = stripBomBuf(bodyBuf).toString();
const body = new TextDecoder().decode(stripBomBuf(bodyBuf));
if(url.match(/^https?:\/\/www\.freietonne\.de\/seekarte\/getOpenLayerPois\.php\?/))
return body;

Wyświetl plik

@ -28,14 +28,14 @@
"tsconfig.json"
],
"dependencies": {
"@types/geojson": "^7946.0.11",
"@types/geojson": "^7946.0.13",
"zod": "^3.22.4"
},
"devDependencies": {
"rimraf": "^5.0.5",
"rollup-plugin-auto-external": "^2.0.0",
"typescript": "^5.2.2",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0"
"vite": "^4.5.0",
"vite-plugin-dts": "^3.6.3"
}
}

Wyświetl plik

@ -37,22 +37,22 @@
"filtrex": "^2.2.3",
"jquery": "^3.7.1",
"jsdom": "^22.1.0",
"linkify-string": "^4.1.1",
"linkifyjs": "^4.1.1",
"linkify-string": "^4.1.2",
"linkifyjs": "^4.1.2",
"lodash-es": "^4.17.21",
"marked": "^9.1.0"
"marked": "^9.1.6"
},
"devDependencies": {
"@types/cheerio": "^0.22.32",
"@types/dompurify": "^3.0.3",
"@types/jquery": "^3.5.21",
"@types/jsdom": "^21.1.3",
"@types/linkifyjs": "^2.1.5",
"@types/cheerio": "^0.22.34",
"@types/dompurify": "^3.0.5",
"@types/jquery": "^3.5.27",
"@types/jsdom": "^21.1.5",
"@types/linkifyjs": "^2.1.7",
"rimraf": "^5.0.5",
"rollup-plugin-auto-external": "^2.0.0",
"typescript": "^5.2.2",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0",
"vite": "^4.5.0",
"vite-plugin-dts": "^3.6.3",
"vitest": "^0.34.6"
}
}

1732
yarn.lock

Plik diff jest za duży Load Diff