From 653f7d3ac709635f18cbd3f6c7bc12bd2a0f730a Mon Sep 17 00:00:00 2001 From: SuprexDE <46200229+SuprexDE@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:34:03 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(editor):=20improve=20UX=20=E2=80=94=20?= =?UTF-8?q?feedback,=20data-loss=20protection,=20marquee,=20camera=20picke?= =?UTF-8?q?r,=20editability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/constants.ts | 7 + src/core/interaction/usePanZoom.ts | 8 + src/core/render/FloorLayer.vue | 3 + src/core/render/RoutePath.vue | 25 ++- src/editor/EditorCanvas.vue | 42 ++++- src/editor/EditorShell.vue | 166 ++++++++++++++--- src/editor/EditorStatusBar.vue | 42 ++++- src/editor/EditorToolbar.vue | 11 +- src/editor/ToolOverlayLayer.vue | 14 +- src/editor/ZoomControls.vue | 52 ++++++ src/editor/innerLineStyles.ts | 8 + src/editor/panels/FiltersEditor.vue | 10 +- src/editor/panels/FloorsEditor.vue | 20 +- src/editor/panels/ImportDialog.vue | 22 ++- src/editor/panels/PalettePanel.vue | 67 ++++++- src/editor/panels/PlacementMarkerEditor.vue | 31 ++-- src/editor/panels/PlacementProperties.vue | 54 +++++- src/editor/panels/PropertiesPanel.vue | 22 ++- src/editor/panels/RoomImagesEditor.vue | 91 +++++++--- src/editor/panels/RoomInnerLinesEditor.vue | 120 ++++++++++++ src/editor/panels/RoomProperties.vue | 59 ++++-- src/editor/panels/RoomWallGapsEditor.vue | 37 ++-- src/editor/panels/ShortcutHelpDialog.vue | 146 +++++++++++++++ src/editor/panels/StartDialog.vue | 12 +- src/editor/panels/ZonesManager.vue | 15 +- src/editor/store/editorStore.ts | 87 ++++++++- src/editor/tools/toolTypes.ts | 10 +- src/editor/tools/useCameraPickMode.ts | 81 +++++++++ src/editor/tools/useCanvasEvents.ts | 45 ++++- src/editor/tools/useRouteEditMode.ts | 191 ++++++++++++++++++++ src/editor/tools/useSelectTool.ts | 157 +++++++++++++++- 31 files changed, 1478 insertions(+), 177 deletions(-) create mode 100644 src/editor/ZoomControls.vue create mode 100644 src/editor/innerLineStyles.ts create mode 100644 src/editor/panels/RoomInnerLinesEditor.vue create mode 100644 src/editor/panels/ShortcutHelpDialog.vue create mode 100644 src/editor/tools/useCameraPickMode.ts create mode 100644 src/editor/tools/useRouteEditMode.ts diff --git a/src/core/constants.ts b/src/core/constants.ts index a4b72aa..18c9c71 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -21,6 +21,8 @@ export const GRID_SNAP_DEFAULT = 5 /** Zoom factor, not percent. */ export const ZOOM_MIN = 0.25 export const ZOOM_MAX = 8 +/** Scale step of the +/− zoom buttons. */ +export const ZOOM_BUTTON_FACTOR = 1.5 /** Fallback for elements without their own `size`, in map units. */ export const ICON_DEFAULT_SIZE = 10 @@ -44,6 +46,8 @@ export const EDITOR_AUTOSAVE_VERSION = 4 export const CURSOR_STORAGE_KEY = 'outlasttrials-maps:custom-cursor' export const AUTOSAVE_DEBOUNCE_MS = 1000 export const UNDO_STACK_LIMIT = 100 +/** Commits with the same coalesce key within this window share one undo snapshot. */ +export const UNDO_COALESCE_MS = 800 export const VALIDATION_DEBOUNCE_MS = 500 @@ -72,6 +76,9 @@ export const DUPLICATE_OFFSET = 5 /** Hit radius for vertex handles and for switching the active drawing end, in map units. */ export const VERTEX_HIT_RADIUS = 4 +/** Below this pointer travel (screen px) an empty-canvas drag counts as a click, not a marquee. */ +export const MARQUEE_MIN_DRAG_PX = 4 + /** Smallest width/height a room can be resized to, in map units (one grid cell). */ export const ROOM_RESIZE_MIN_SIZE = 5 diff --git a/src/core/interaction/usePanZoom.ts b/src/core/interaction/usePanZoom.ts index 4e7990e..e1c74ac 100644 --- a/src/core/interaction/usePanZoom.ts +++ b/src/core/interaction/usePanZoom.ts @@ -82,6 +82,13 @@ export function usePanZoom(svgRef: Readonly>, options? window.removeEventListener('keyup', onKeyUp) }) + /** Scales around the viewport center; d3 clamps against the scale extent. */ + function zoomBy(factor: number): void { + if (behavior && selection) { + behavior.scaleBy(selection, factor) + } + } + /** Fits the view to the given world bounds (without bounds: identity). */ function resetView(bounds?: WorldBounds): void { const svg = svgRef.value @@ -119,5 +126,6 @@ export function usePanZoom(svgRef: Readonly>, options? transform: readonly(transform), isSpacePanning: readonly(isSpacePanning), resetView, + zoomBy, } } diff --git a/src/core/render/FloorLayer.vue b/src/core/render/FloorLayer.vue index 84be378..949edad 100644 --- a/src/core/render/FloorLayer.vue +++ b/src/core/render/FloorLayer.vue @@ -14,6 +14,8 @@ const props = defineProps<{ zones: ReadonlyMap selectedIds?: ReadonlySet hiddenCategories?: ReadonlySet + /** Editor: routes get an invisible wide hit stroke for clicking/double-clicking. */ + interactiveRoutes?: boolean }>() function visible(items: T[]): T[] { @@ -46,6 +48,7 @@ const markedPlacements = computed(() => placements.value.filter((placement) => p :key="route.id" :route="route" :selected="selectedIds?.has(route.id)" + :hit-area="interactiveRoutes" /> () diff --git a/src/editor/EditorCanvas.vue b/src/editor/EditorCanvas.vue index ffcda0a..f833c5f 100644 --- a/src/editor/EditorCanvas.vue +++ b/src/editor/EditorCanvas.vue @@ -3,7 +3,8 @@ import { computed, onMounted, ref, watch } from 'vue' import { useElementSize } from '../core/interaction/useElementSize' import { usePanZoom } from '../core/interaction/usePanZoom' import { roomsBounds } from '../core/model/roomPath' -import type { Vec2 } from '../core/model/types' +import type { CameraInfo, Vec2 } from '../core/model/types' +import CameraMarker from '../core/render/CameraMarker.vue' import FloorLayer from '../core/render/FloorLayer.vue' import GridLayer from '../core/render/GridLayer.vue' import MapCanvas from '../core/render/MapCanvas.vue' @@ -20,6 +21,7 @@ import { useSelectTool } from './tools/useSelectTool' const emit = defineEmits<{ cursorMove: [pos: Vec2 | null] zoomChange: [k: number] + fineGridChange: [fine: boolean] }>() const editor = useEditorStore() @@ -28,7 +30,7 @@ const zonesStore = useZonesStore() const canvas = ref | null>(null) const svgEl = computed(() => canvas.value?.svgEl ?? null) -const { transform, isSpacePanning, resetView } = usePanZoom(svgEl) +const { transform, isSpacePanning, resetView, zoomBy } = usePanZoom(svgEl) const viewport = useElementSize(svgEl) const tools = { @@ -40,6 +42,7 @@ const tools = { const { cursorWorld, + isFineGrid, overlay, onPointerDown, onPointerMove, @@ -47,7 +50,13 @@ const { onPointerLeave, onDblClick, onContextMenu, -} = useCanvasEvents({ svgRef: svgEl, transform, isSpacePanning, tools }) +} = useCanvasEvents({ + svgRef: svgEl, + transform, + isSpacePanning, + tools, + fitView: () => fitToDocument(), +}) watch(cursorWorld, (pos) => emit('cursorMove', pos)) watch( @@ -55,6 +64,19 @@ watch( (k) => emit('zoomChange', k), { immediate: true }, ) +watch(isFineGrid, (fine) => emit('fineGridChange', fine)) + +const isDrawTool = computed(() => editor.activeTool !== 'select') + +const selectedRoomCameras = computed(() => { + const room = editor.selectedRoom + if (!room || room.floor !== editor.activeFloor) { + return [] + } + return (room.info?.images ?? []) + .map((image) => image.camera) + .filter((camera): camera is CameraInfo => Boolean(camera)) +}) onMounted(() => fitToDocument()) watch( @@ -70,13 +92,15 @@ function fitToDocument(): void { } resetView(roomsBounds(doc.rooms) ?? undefined) } + +defineExpose({ fitToDocument, zoomBy })