diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..a125c9e --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,104 @@ +name: Preview + +on: + pull_request_target: + types: [labeled, unlabeled, synchronize, reopened, closed] + +concurrency: + group: preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + build: + if: >- + (github.event.action == 'labeled' && github.event.label.name == 'preview') || + (contains(fromJSON('["synchronize", "reopened"]'), github.event.action) && + contains(github.event.pull_request.labels.*.name, 'preview')) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + sha: ${{ steps.head.outputs.sha }} + steps: + - uses: actions/checkout@v7 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + - id: head + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: pnpm install --frozen-lockfile + - run: pnpm build --base=/pr-preview/pr-${{ github.event.pull_request.number }}/ + - name: Remove root-only CNAME + run: rm -f dist/CNAME + - uses: actions/upload-artifact@v4 + with: + name: preview-dist + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + concurrency: + group: gh-pages-push + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v4 + with: + name: preview-dist + path: dist + - name: Deploy preview to gh-pages + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: dist + branch: gh-pages + target-folder: pr-preview/pr-${{ github.event.pull_request.number }} + clean: true + force: false + commit-message: "Deploy preview for PR #${{ github.event.pull_request.number }}" + - uses: marocchino/sticky-pull-request-comment@v2 + with: + header: preview + number: ${{ github.event.pull_request.number }} + message: | + :mag: Preview: https://maps.outlasttrialsstats.com/pr-preview/pr-${{ github.event.pull_request.number }}/ + + Built from ${{ needs.build.outputs.sha }}. Updates on every push while the `preview` label is set. + Note: page reloads and deep links inside the preview are not supported. + + cleanup: + if: >- + github.event.action == 'closed' || + (github.event.action == 'unlabeled' && github.event.label.name == 'preview') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + concurrency: + group: gh-pages-push + steps: + - uses: actions/checkout@v7 + - name: Remove preview folder from gh-pages + env: + PR: ${{ github.event.pull_request.number }} + run: | + git fetch origin gh-pages:gh-pages --depth=1 || exit 0 + git switch gh-pages + [ -d "pr-preview/pr-$PR" ] || exit 0 + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git rm -r -q "pr-preview/pr-$PR" + git commit -m "Remove preview for PR #$PR" + git push origin gh-pages + - uses: marocchino/sticky-pull-request-comment@v2 + with: + header: preview + number: ${{ github.event.pull_request.number }} + delete: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 91468d1..c82abe4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,11 +22,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: gh-pages-push steps: - name: Ensure release runs from main if: github.ref_name != 'main' @@ -78,15 +75,14 @@ jobs: - name: Add SPA fallback run: cp dist/index.html dist/404.html - - uses: actions/configure-pages@v6 - - - uses: actions/upload-pages-artifact@v5 + - name: Deploy to gh-pages + uses: JamesIves/github-pages-deploy-action@v4 with: - path: dist - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 + folder: dist + branch: gh-pages + clean: true + clean-exclude: pr-preview/ + force: false - name: Commit, tag and push env: @@ -108,10 +104,9 @@ jobs: - name: Summary env: VERSION: ${{ steps.bump.outputs.version }} - PAGE_URL: ${{ steps.deployment.outputs.page_url }} run: | { echo "### Released v$VERSION" echo - echo "Deployed to $PAGE_URL" + echo "Deployed to https://maps.outlasttrialsstats.com" } >> "$GITHUB_STEP_SUMMARY" diff --git a/public/CNAME b/public/CNAME new file mode 100644 index 0000000..1791f7d --- /dev/null +++ b/public/CNAME @@ -0,0 +1 @@ +maps.outlasttrialsstats.com diff --git a/src/App.vue b/src/App.vue index 0358ff4..d478211 100644 --- a/src/App.vue +++ b/src/App.vue @@ -43,7 +43,7 @@ function releasePressed() { onMounted(() => { // Preload the pressed variant so the first click does not flicker - new Image().src = '/images/cursor/cursor_pressed.webp' + new Image().src = `${import.meta.env.BASE_URL}images/cursor/cursor_pressed.webp` window.addEventListener('pointermove', onPointerMove, { passive: true }) window.addEventListener('pointerdown', onPointerDown) diff --git a/src/core/constants.ts b/src/core/constants.ts index a4b72aa..f895ce1 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -1,10 +1,10 @@ -/** Base URL of all content fetched at runtime (public/data). */ -export const DATA_BASE_URL = '/data' +/** Base URL of all content fetched at runtime (public/data); BASE_URL always ends with '/'. */ +export const DATA_BASE_URL = `${import.meta.env.BASE_URL}data` export const MAPS_INDEX_URL = `${DATA_BASE_URL}/maps/index.json` export const ELEMENT_LIBRARY_URL = `${DATA_BASE_URL}/elements.json` export const ZONE_LIBRARY_URL = `${DATA_BASE_URL}/zones.json` export const CONTRIBUTORS_URL = `${DATA_BASE_URL}/contributors.json` -export const SCHEMA_BASE_URL = '/schemas' +export const SCHEMA_BASE_URL = `${import.meta.env.BASE_URL}schemas` /** Repo behind the join link of the contributors section. */ export const GITHUB_REPO_URL = 'https://github.com/OutlastTrialsStats/maps' @@ -21,6 +21,14 @@ 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 + +/** Wheel deltas in line mode (Firefox) are converted to px before panning. */ +export const WHEEL_LINE_HEIGHT_PX = 16 + +/** Below this pointer travel (screen px) a right-drag stays a plain right-click. */ +export const RIGHT_DRAG_PAN_THRESHOLD_PX = 4 /** Fallback for elements without their own `size`, in map units. */ export const ICON_DEFAULT_SIZE = 10 @@ -44,6 +52,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 @@ -56,6 +66,18 @@ export const GRID_MIN_SPACING_PX = 8 export const NUDGE_STEP = 1 export const NUDGE_STEP_LARGE = 5 +/** Direction per arrow key; y grows downwards (screen and world alike). */ +export const ARROW_DIRECTIONS: Record = { + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, -1], + ArrowDown: [0, 1], +} + +/** Arrow-key view panning (empty selection), in screen px; Shift uses the large step. */ +export const VIEW_PAN_STEP_PX = 40 +export const VIEW_PAN_STEP_LARGE_PX = 200 + /** * Rotations are limited to fixed steps (R key and properties select); * in sync with the `multipleOf` of the `rotation` field in the map schema. @@ -69,8 +91,11 @@ export const ROTATION_VALUES = Array.from( /** Offset of duplicated objects (Ctrl+D) and of a paste without cursor position, in map units. */ 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 +/** Hit radius of vertex/resize handles in screen px; divided by the zoom for world-space hit tests. */ +export const VERTEX_HIT_RADIUS_PX = 8 + +/** 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..d7c19d2 100644 --- a/src/core/interaction/usePanZoom.ts +++ b/src/core/interaction/usePanZoom.ts @@ -1,7 +1,13 @@ import { onBeforeUnmount, onMounted, readonly, ref, type Ref } from 'vue' import { select, type Selection } from 'd3-selection' import { zoom, zoomIdentity, type D3ZoomEvent, type ZoomBehavior } from 'd3-zoom' -import { FIT_VIEW_PADDING_RATIO, ZOOM_MAX, ZOOM_MIN } from '../constants' +import { + FIT_VIEW_PADDING_RATIO, + RIGHT_DRAG_PAN_THRESHOLD_PX, + WHEEL_LINE_HEIGHT_PX, + ZOOM_MAX, + ZOOM_MIN, +} from '../constants' import type { Vec2 } from '../model/types' import { isEditableTarget } from './eventTargets' import type { ViewTransform } from './viewTransform' @@ -14,18 +20,24 @@ export interface WorldBounds { export interface PanZoomOptions { /** Pan with the left mouse button as well — in the editor it stays reserved for the tools. */ dragPan?: boolean + /** Pan with the right mouse button; a right-click without movement keeps its own action. */ + rightDragPan?: boolean } /** * Wraps d3-zoom: mouse wheel zoom at the cursor position and pinch are always - * active, panning via the middle mouse button, a held space bar or (with - * `dragPan`) directly via the left mouse button. + * active, Shift/Alt + wheel pans instead, and dragging pans via the middle + * mouse button, a held space bar, the right button (`rightDragPan`) or the + * left button (`dragPan`). */ export function usePanZoom(svgRef: Readonly>, options?: PanZoomOptions) { const transform = ref({ x: 0, y: 0, k: 1 }) const isSpacePanning = ref(false) + const isPanning = ref(false) let behavior: ZoomBehavior | null = null let selection: Selection | null = null + let rightDragStart: Vec2 | null = null + let rightDragMoved = false const onKeyDown = (event: KeyboardEvent) => { if ( @@ -42,12 +54,56 @@ export function usePanZoom(svgRef: Readonly>, options? isSpacePanning.value = false } } + const onWindowBlur = () => { + isSpacePanning.value = false + } const preventMiddleClickScroll = (event: PointerEvent) => { if (event.button === 1) { event.preventDefault() } } + const onWheelPan = (event: WheelEvent) => { + if (!event.shiftKey && !event.altKey) { + return + } + // A shifted wheel arrives as deltaX on some platforms. + const raw = event.deltaX !== 0 ? event.deltaX : event.deltaY + const pixels = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? raw * WHEEL_LINE_HEIGHT_PX : raw + panBy(event.shiftKey ? pixels : 0, event.shiftKey ? 0 : pixels) + event.preventDefault() + } + + const beginRightDrag = (event: PointerEvent) => { + if (event.button === 2) { + rightDragStart = [event.clientX, event.clientY] + rightDragMoved = false + } + } + const trackRightDrag = (event: PointerEvent) => { + if (!rightDragStart || rightDragMoved) { + return + } + const travel = Math.hypot(event.clientX - rightDragStart[0], event.clientY - rightDragStart[1]) + rightDragMoved = travel > RIGHT_DRAG_PAN_THRESHOLD_PX + } + /** `rightDragMoved` has to survive this: `contextmenu` only fires after the pointer-up. */ + const endRightDrag = () => { + rightDragStart = null + } + /** + * Capture on window so it runs before the listeners on the canvas itself: + * a right-drag must neither open the menu nor trigger the tool's own action. + */ + const suppressContextMenuAfterDrag = (event: MouseEvent) => { + if (!rightDragMoved) { + return + } + rightDragMoved = false + event.preventDefault() + event.stopPropagation() + } + onMounted(() => { const svg = svgRef.value if (!svg) { @@ -56,32 +112,74 @@ export function usePanZoom(svgRef: Readonly>, options? behavior = zoom() .scaleExtent([ZOOM_MIN, ZOOM_MAX]) .filter((event: Event) => { - if (event.type === 'wheel' || event.type.startsWith('touch')) { + if (event.type === 'wheel') { + const wheel = event as WheelEvent + return !wheel.shiftKey && !wheel.altKey + } + if (event.type.startsWith('touch')) { return true } const mouse = event as MouseEvent return ( mouse.button === 1 || + (mouse.button === 2 && options?.rightDragPan === true) || (mouse.button === 0 && (options?.dragPan === true || isSpacePanning.value)) ) }) + .on('start', (event: D3ZoomEvent) => { + // Only a real drag grabs — wheel zoom and programmatic transforms must not. + isPanning.value = (event.sourceEvent as Event | null)?.type === 'mousedown' + }) .on('zoom', (event: D3ZoomEvent) => { transform.value = { x: event.transform.x, y: event.transform.y, k: event.transform.k } }) + .on('end', () => { + isPanning.value = false + }) selection = select(svg) selection.call(behavior) svg.addEventListener('pointerdown', preventMiddleClickScroll) + svg.addEventListener('wheel', onWheelPan, { passive: false }) + if (options?.rightDragPan === true) { + svg.addEventListener('pointerdown', beginRightDrag) + window.addEventListener('pointermove', trackRightDrag) + window.addEventListener('pointerup', endRightDrag) + window.addEventListener('pointercancel', endRightDrag) + window.addEventListener('contextmenu', suppressContextMenuAfterDrag, true) + } window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onWindowBlur) }) onBeforeUnmount(() => { selection?.on('.zoom', null) svgRef.value?.removeEventListener('pointerdown', preventMiddleClickScroll) + svgRef.value?.removeEventListener('wheel', onWheelPan) + svgRef.value?.removeEventListener('pointerdown', beginRightDrag) + window.removeEventListener('pointermove', trackRightDrag) + window.removeEventListener('pointerup', endRightDrag) + window.removeEventListener('pointercancel', endRightDrag) + window.removeEventListener('contextmenu', suppressContextMenuAfterDrag, true) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onWindowBlur) }) + /** Moves the viewport by screen pixels; d3 translates in world units, hence the division by k. */ + function panBy(dx: number, dy: number): void { + if (behavior && selection) { + behavior.translateBy(selection, -dx / transform.value.k, -dy / transform.value.k) + } + } + + /** 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 @@ -118,6 +216,9 @@ export function usePanZoom(svgRef: Readonly>, options? return { transform: readonly(transform), isSpacePanning: readonly(isSpacePanning), + isPanning: readonly(isPanning), resetView, + zoomBy, + panBy, } } 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..5076c6a 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,9 @@ const zonesStore = useZonesStore() const canvas = ref | null>(null) const svgEl = computed(() => canvas.value?.svgEl ?? null) -const { transform, isSpacePanning, resetView } = usePanZoom(svgEl) +const { transform, isSpacePanning, isPanning, resetView, zoomBy, panBy } = usePanZoom(svgEl, { + rightDragPan: true, +}) const viewport = useElementSize(svgEl) const tools = { @@ -40,6 +44,7 @@ const tools = { const { cursorWorld, + isFineGrid, overlay, onPointerDown, onPointerMove, @@ -47,7 +52,14 @@ const { onPointerLeave, onDblClick, onContextMenu, -} = useCanvasEvents({ svgRef: svgEl, transform, isSpacePanning, tools }) +} = useCanvasEvents({ + svgRef: svgEl, + transform, + isSpacePanning, + tools, + fitView: () => fitToDocument(), + panBy, +}) watch(cursorWorld, (pos) => emit('cursorMove', pos)) watch( @@ -55,6 +67,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 +95,19 @@ function fitToDocument(): void { } resetView(roomsBounds(doc.rooms) ?? undefined) } + +defineExpose({ fitToDocument, zoomBy }) @@ -101,4 +142,12 @@ function fitToDocument(): void { .panning { cursor: grab; } + +.draw-cursor { + cursor: crosshair; +} + +.grabbing { + cursor: grabbing; +} diff --git a/src/editor/EditorShell.vue b/src/editor/EditorShell.vue index 1e8e44b..9dc3866 100644 --- a/src/editor/EditorShell.vue +++ b/src/editor/EditorShell.vue @@ -1,10 +1,16 @@ @@ -62,20 +114,33 @@ onMounted(() => { @@ -98,16 +175,29 @@ onMounted(() => {
- + +