@@ -11,13 +11,15 @@ | |||
"openseadragon": "^5.0.1", | |||
"pinia": "^2.2.6", | |||
"vue": "^3.5.13", | |||
"vue-router": "^4.4.5" | |||
"vue-router": "^4.4.5", | |||
"vuedraggable": "^4.1.0" | |||
}, | |||
"devDependencies": { | |||
"@playwright/test": "^1.49.0", | |||
"@tsconfig/node22": "^22.0.0", | |||
"@types/jsdom": "^21.1.7", | |||
"@types/node": "^22.9.3", | |||
"@types/openseadragon": "^3.0.10", | |||
"@vitejs/plugin-vue": "^5.2.1", | |||
"@vitest/eslint-plugin": "1.1.10", | |||
"@vue/eslint-config-prettier": "^10.1.0", | |||
@@ -1622,6 +1624,12 @@ | |||
"undici-types": "~6.20.0" | |||
} | |||
}, | |||
"node_modules/@types/openseadragon": { | |||
"version": "3.0.10", | |||
"resolved": "https://registry.npmjs.org/@types/openseadragon/-/openseadragon-3.0.10.tgz", | |||
"integrity": "sha512-nNVlu9PUNDVTudkSqVWhHtghCsyzQrJObEtN5zsVN2ghh5HYAC6U6xAcT9NQRfVfZVZkWXEYp2Hw7hQgwRxqgg==", | |||
"dev": true | |||
}, | |||
"node_modules/@types/tough-cookie": { | |||
"version": "4.0.5", | |||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", | |||
@@ -5738,6 +5746,11 @@ | |||
"node": ">=18" | |||
} | |||
}, | |||
"node_modules/sortablejs": { | |||
"version": "1.14.0", | |||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", | |||
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==" | |||
}, | |||
"node_modules/source-map-js": { | |||
"version": "1.2.1", | |||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", | |||
@@ -7718,6 +7731,17 @@ | |||
"typescript": ">=5.0.0" | |||
} | |||
}, | |||
"node_modules/vuedraggable": { | |||
"version": "4.1.0", | |||
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", | |||
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", | |||
"dependencies": { | |||
"sortablejs": "1.14.0" | |||
}, | |||
"peerDependencies": { | |||
"vue": "^3.0.1" | |||
} | |||
}, | |||
"node_modules/w3c-xmlserializer": { | |||
"version": "5.0.0", | |||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", | |||
@@ -4,7 +4,7 @@ | |||
"private": true, | |||
"type": "module", | |||
"scripts": { | |||
"dev": "vite", | |||
"dev": "vite --host", | |||
"build": "run-p type-check \"build-only {@}\" --", | |||
"preview": "vite preview", | |||
"test:unit": "vitest", | |||
@@ -18,13 +18,15 @@ | |||
"openseadragon": "^5.0.1", | |||
"pinia": "^2.2.6", | |||
"vue": "^3.5.13", | |||
"vue-router": "^4.4.5" | |||
"vue-router": "^4.4.5", | |||
"vuedraggable": "^4.1.0" | |||
}, | |||
"devDependencies": { | |||
"@playwright/test": "^1.49.0", | |||
"@tsconfig/node22": "^22.0.0", | |||
"@types/jsdom": "^21.1.7", | |||
"@types/node": "^22.9.3", | |||
"@types/openseadragon": "^3.0.10", | |||
"@vitejs/plugin-vue": "^5.2.1", | |||
"@vitest/eslint-plugin": "1.1.10", | |||
"@vue/eslint-config-prettier": "^10.1.0", | |||
@@ -2,7 +2,7 @@ | |||
<div> | |||
<main class="inline-block bg-white bg-opacity-50 m-10 relative z-10 top-0 left-0"> | |||
<!-- <ZoomRect /> --> | |||
<ZoomPoint :markers="story[0].markers" /> | |||
<StoryEditor :markers="story[0].markers" /> | |||
</main> | |||
<aside class="fixed top-0 left-0 w-screen h-screen" ref="openSeadragonElt"></aside> | |||
</div> | |||
@@ -19,7 +19,8 @@ import Vignemale from '../public/deepzoom/vignemale/dz/info.json' | |||
const Cells = 'https://verrochi92.github.io/axolotl/data/W255B_0.dzi' | |||
import ZoomRect from './components/tools/ZoomRect.vue' | |||
import ZoomPoint from './components/tools/ZoomPoint.vue' | |||
import StoryEditor from './components/tools/StoryEditor.vue' | |||
import TestSortable from './components/tools/TestSortable.vue' | |||
const Viewer = ref() | |||
const openSeadragonElt = ref() | |||
@@ -31,16 +32,20 @@ const initViewer = () => { | |||
element: openSeadragonElt.value, | |||
animationTime: 0.4, | |||
prefixUrl: 'https://openseadragon.github.io/openseadragon/images/', | |||
showNavigator: true, | |||
sequenceMode: true, | |||
showNavigator: false, | |||
sequenceMode: false, | |||
tileSources: Poulpatore, | |||
showNavigationControl: true, | |||
showNavigationControl: false, | |||
drawer: 'canvas', | |||
preventDefaultAction: true, | |||
homeFillsViewer: true, | |||
visibilityRatio: 1, | |||
crossOriginPolicy: 'Anonymous', | |||
showZoomControl: true, | |||
gestureSettingsMouse: { | |||
scrollToZoom: true, | |||
clickToZoom: false, | |||
dblClickToZoom: true, | |||
dragToPan: true, | |||
}, | |||
}) | |||
} | |||
@@ -5,28 +5,34 @@ | |||
"url": "https://verrochi92.github.io/axolotl/data/W255B_0.dzi", | |||
"markers": [ | |||
{ | |||
"id": 0, | |||
"name": "first", | |||
"order": 0, | |||
"position": { | |||
"x": 0.2, | |||
"y": 0.2 | |||
"x": 0.6, | |||
"y": 0.7 | |||
}, | |||
"zoom": 4, | |||
"annotation": "<b>lorem</b> ipsum dolor sit amet" | |||
}, | |||
{ | |||
"id": 1, | |||
"name": "second", | |||
"order": 1, | |||
"position": { | |||
"x": 0.2, | |||
"x": 0.68, | |||
"y": 0.3 | |||
}, | |||
"zoom": 3, | |||
"zoom": 8, | |||
"annotation": "<b>second</b> annotation" | |||
}, | |||
{ | |||
"id": 2, | |||
"name": "third", | |||
"order": 2, | |||
"position": { | |||
"x": 0.1, | |||
"y": 0.1 | |||
"x": 0.5, | |||
"y": 0.2 | |||
}, | |||
"zoom": 7, | |||
"annotation": "<b>third</b>" | |||
@@ -0,0 +1,17 @@ | |||
# Story Editor | |||
## Props: | |||
story: Story (should be a state as Markers are CRUD ?) | |||
## Ref: | |||
selectedMarker: Marker | |||
## Methods: | |||
LoadStory | |||
CreateMarker | |||
SelectMarker | |||
EditMarker | |||
DeleteMarker |
@@ -0,0 +1,198 @@ | |||
<template> | |||
<nav class="[&>*]:m-3 text-black w-48"> | |||
<div class="flex flex-col ui"> | |||
<button @click="goHome">home</button> | |||
<button @click="addMarker">Add Marker</button> | |||
<div class="flex flex-col my-6"> | |||
<button | |||
v-for="marker in sortedMarkers" | |||
:key="marker.order" | |||
@click="selectMarker(marker)" | |||
:class="marker.id === selectedMarker.id ? 'text-green-500 text-bold' : ''" | |||
> | |||
{{ marker.name }} | |||
</button> | |||
</div> | |||
<div v-if="isSelectionModeOn"> | |||
<input | |||
ref="textInputMarkerName" | |||
type="text" | |||
v-model="selectedMarker.name" | |||
class="w-40 my-4" | |||
/> | |||
<textarea v-model="selectedMarker.annotation" class="w-40 h-40"></textarea> | |||
<button @click="saveMarker">Save</button> | |||
<button @click="unselectMarker">Cancel</button> | |||
</div> | |||
</div> | |||
</nav> | |||
</template> | |||
<script setup lang="ts"> | |||
import type { Marker } from '@/types/virages' | |||
import { onMounted, ref, inject, nextTick, type Ref, computed } from 'vue' | |||
import OpenSeadragon, { imageFormatSupported } from 'openseadragon' | |||
const props = defineProps<{ markers: Marker[] }>() | |||
const Viewer = inject<Ref<OpenSeadragon.Viewer>>('Viewer') | |||
const isEditMode = ref<boolean>(false) | |||
const selectedMarker = ref<Marker>({} as Marker) | |||
const textInputMarkerName = ref<HTMLInputElement | null>(null) | |||
const isSelectionModeOn = computed(() => { | |||
return selectedMarker.value.id !== undefined | |||
}) | |||
const sortedMarkers = computed(() => { | |||
return [...props.markers].sort((a, b) => a.order - b.order) | |||
}) | |||
const goHome = () => { | |||
Viewer?.value.viewport.goHome(false) | |||
} | |||
const zoomPlus = () => { | |||
Viewer?.value.viewport.zoomBy(1.2) | |||
Viewer?.value.viewport.applyConstraints() | |||
} | |||
const zoomMinus = () => { | |||
Viewer?.value.viewport.zoomBy(0.8) | |||
Viewer?.value.viewport.applyConstraints() | |||
} | |||
const addMarker = () => { | |||
isEditMode.value = true | |||
document.querySelector('.openseadragon-canvas').classList.add('cursor-crosshair') | |||
} | |||
const saveMarker = () => { | |||
unselectMarker() | |||
} | |||
const selectMarker = (marker: Marker) => { | |||
document.querySelectorAll('.marker-selected').forEach((el) => { | |||
el.classList.remove('marker-selected') | |||
}) | |||
const theMarker = document.querySelector('.marker-id-' + marker.id) | |||
theMarker.classList.add('marker-selected') | |||
Viewer?.value.viewport.zoomTo(marker.zoom, undefined, false) | |||
Viewer?.value.viewport.panTo(new OpenSeadragon.Point(marker.position.x, marker.position.y)) | |||
selectedMarker.value = marker | |||
nextTick(() => { | |||
textInputMarkerName.value?.focus() | |||
}) | |||
} | |||
const unselectMarker = () => { | |||
selectedMarker.value = {} as Marker | |||
document.querySelector('.marker-selected')?.classList.remove('marker-selected') | |||
} | |||
const injectMarker = (marker: Marker) => { | |||
const overlay = document.createElement('button') | |||
overlay.className = `marker-id-${marker.id} marker` | |||
overlay.title = marker.name | |||
overlay.onfocus = function () { | |||
selectMarker(marker) | |||
} | |||
overlay.addEventListener('click', function () { | |||
selectMarker(marker) | |||
}) | |||
overlay.addEventListener('mouseover', function () { | |||
Viewer?.value.setMouseNavEnabled(false) | |||
Viewer.value.gestureSettingsMouse.dragToPan = false | |||
}) | |||
overlay.addEventListener('mouseout', function () { | |||
Viewer?.value.setMouseNavEnabled(true) | |||
}) | |||
// Injection de l'overlay | |||
Viewer?.value.addOverlay({ | |||
element: overlay, | |||
location: new OpenSeadragon.Point(marker.position.x, marker.position.y), | |||
}) | |||
} | |||
const createMarkerOnClick = () => { | |||
Viewer?.value.addHandler('canvas-click', (e) => { | |||
if (isEditMode.value) { | |||
const point = Viewer?.value.viewport.pointFromPixel(e.position) | |||
const zoom = Viewer?.value.viewport.getZoom() | |||
const newMarker: Marker = { | |||
id: props.markers.length, // WIP | |||
order: props.markers.length, // WIP | |||
annotation: '', | |||
position: new OpenSeadragon.Point(point.x, point.y), | |||
zoom: zoom, | |||
} | |||
injectMarker(newMarker) | |||
// isEditMode.value = false | |||
document.querySelector('.openseadragon-canvas').classList.remove('cursor-crosshair') | |||
selectMarker(newMarker) | |||
} else { | |||
unselectMarker() | |||
} | |||
}) | |||
} | |||
const editMarkerOnDragOrZoom = () => { | |||
Viewer?.value.addHandler('canvas-drag', (event) => { | |||
if (isSelectionModeOn.value) { | |||
Viewer.value.gestureSettingsMouse.dragToPan = false | |||
const point = Viewer?.value.viewport.pointFromPixel(event.position) | |||
selectedMarker.value.position = point | |||
const overlay = document.querySelector('.marker-id-' + selectedMarker.value.id) | |||
Viewer?.value.updateOverlay(overlay as Element, point) | |||
} | |||
}) | |||
Viewer?.value.addHandler('canvas-release', () => { | |||
Viewer.value.gestureSettingsMouse.dragToPan = true | |||
}) | |||
Viewer?.value.addHandler('zoom', (event) => { | |||
if (isSelectionModeOn.value) { | |||
selectedMarker.value.zoom = event.zoom | |||
} | |||
}) | |||
} | |||
const loadStory = (markers: Marker[]) => { | |||
markers.forEach((marker) => { | |||
injectMarker(marker) | |||
}) | |||
} | |||
const keyboardShortcut = () => { | |||
// if press on escpae key | |||
window.addEventListener('keydown', (e) => { | |||
unselectMarker() | |||
goHome() | |||
}) | |||
} | |||
onMounted(() => { | |||
nextTick(() => { | |||
loadStory(props.markers) | |||
createMarkerOnClick() | |||
editMarkerOnDragOrZoom() | |||
keyboardShortcut() | |||
}) | |||
}) | |||
</script> | |||
<style> | |||
.ui button { | |||
@apply border-2 border-black px-4 py-2 rounded-xl bg-slate-300 hover:bg-neutral-100 m-1; | |||
} | |||
.ui input, | |||
.ui textarea { | |||
@apply border-2 border-black px-4 py-2; | |||
} | |||
.marker { | |||
@apply w-8 h-8 rounded-full shadow-2xl bg-green-500 bg-opacity-50 hover:bg-green-600 hover:bg-opacity-100 hover:cursor-pointer pointer-events-auto hover:scale-150 transition-all border-2 border-white border-8 transform -translate-y-1/2 -translate-x-1/2; | |||
} | |||
.marker-selected { | |||
@apply border-blue-400 border-8 bg-white animate-pulse hover:cursor-move hover:bg-white; | |||
} | |||
</style> |
@@ -0,0 +1,83 @@ | |||
<template> | |||
<nav class="[&>*]:m-3 text-black"> | |||
<div class="flex flex-col ui"> | |||
<h2>Current Marker</h2> | |||
{{ currentMarker }} | |||
<textarea :value="currentMarker.annotation"></textarea> | |||
</div> | |||
</nav> | |||
</template> | |||
<script setup lang="ts"> | |||
import type { Marker } from '@/types/virages' | |||
import { onMounted, ref, inject, nextTick } from 'vue' | |||
import OpenSeadragon from 'openseadragon' | |||
const props = defineProps<{ markers: Marker[] }>() | |||
const Viewer = inject('Viewer') | |||
const isEditMode = ref<boolean>(false) | |||
const currentMarker = ref<Marker>({} as Marker) | |||
const injectMarker = (marker: Marker) => { | |||
const overlay = document.createElement('button') | |||
overlay.className = `marker-id-${marker.id} w-8 h-8 rounded-full shadow-2xl bg-green-500 bg-opacity-50 hover:bg-green-600 hover:bg-opacity-100 z-index-20 hover:cursor-pointer pointer-events-auto hover:scale-150 transition-all border-2 border-white border-8 transform -translate-y-1/2 -translate-x-1/2` | |||
overlay.addEventListener('click', function () { | |||
Viewer?.value.viewport.zoomTo(marker.zoom, undefined, false) | |||
Viewer?.value.viewport.panTo(new OpenSeadragon.Point(marker.position.x, marker.position.y)) | |||
selectMarker(marker) | |||
}) | |||
overlay.addEventListener('mouseover', function () { | |||
Viewer?.value.setMouseNavEnabled(false) | |||
}) | |||
overlay.addEventListener('mouseout', function () { | |||
Viewer?.value.setMouseNavEnabled(true) | |||
}) | |||
// Injection de l'overlay | |||
Viewer?.value.addOverlay({ | |||
element: overlay, | |||
location: new OpenSeadragon.Point(marker.position.x, marker.position.y), | |||
}) | |||
} | |||
const createMarkerOnClick = () => { | |||
Viewer.value.addHandler('canvas-click', (e) => { | |||
if (!isEditMode.value) return | |||
const point = Viewer.value.viewport.pointFromPixel(e.position) | |||
const zoom = Viewer.value.viewport.getZoom() | |||
const newMarker: Marker = { | |||
id: props.markers.length, // WIP | |||
order: props.markers.length, // WIP | |||
annotation: '', | |||
position: { | |||
x: point.x, | |||
y: point.y, | |||
}, | |||
zoom: zoom, | |||
} | |||
injectMarker(newMarker) | |||
isEditMode.value = false | |||
}) | |||
} | |||
const loadStory = (markers: Marker[]) => { | |||
markers.forEach((marker) => { | |||
injectMarker(marker) | |||
}) | |||
} | |||
onMounted(() => { | |||
nextTick(() => { | |||
Viewer.value.zoomPerClick = true | |||
createMarkerOnClick() | |||
loadStory(props.markers) | |||
}) | |||
}) | |||
</script> | |||
<style> | |||
.ui button { | |||
@apply border-2 border-black px-4 py-2 rounded-xl bg-slate-300; | |||
} | |||
</style> |
@@ -0,0 +1,74 @@ | |||
<template> | |||
<div> | |||
<draggable | |||
v-model="markers" | |||
item-key="id" | |||
@end="onDragEnd" | |||
animation="200" | |||
class="list" | |||
ghost-class="dragging" | |||
> | |||
<template #item="{ element }"> | |||
<div> | |||
<!-- Un seul élément parent ici --> | |||
<button | |||
@click="selectMarker(element)" | |||
:class="element.id === selectedMarker?.id ? 'text-green-500' : ''" | |||
> | |||
{{ element.name }} | |||
</button> | |||
</div> | |||
</template> | |||
</draggable> | |||
</div> | |||
</template> | |||
<script> | |||
import { ref } from 'vue' | |||
import draggable from 'vuedraggable' | |||
export default { | |||
components: { draggable }, | |||
setup() { | |||
const markers = ref([ | |||
{ id: 1, name: 'Marker 1', order: 2 }, | |||
{ id: 2, name: 'Marker 2', order: 1 }, | |||
{ id: 3, name: 'Marker 3', order: 3 }, | |||
]) | |||
const selectedMarker = ref(null) | |||
const selectMarker = (marker) => { | |||
selectedMarker.value = marker | |||
} | |||
const onDragEnd = () => { | |||
// Optionnel : Mettre à jour les ordres si nécessaire | |||
markers.value.forEach((marker, index) => { | |||
marker.order = index + 1 | |||
}) | |||
} | |||
return { | |||
markers, | |||
selectedMarker, | |||
selectMarker, | |||
onDragEnd, | |||
} | |||
}, | |||
} | |||
</script> | |||
<style scoped> | |||
.list { | |||
display: flex; | |||
flex-direction: column; | |||
gap: 10px; | |||
} | |||
.dragging { | |||
background: rgba(0, 0, 0, 0.1); | |||
} | |||
.text-green-500 { | |||
color: green; | |||
} | |||
</style> |
@@ -1,86 +0,0 @@ | |||
<template> | |||
<nav class="[&>*]:m-3 text-black"> | |||
<div class="flex flex-col"> | |||
<h2>Current Marker</h2> | |||
{{ currentMarker }} | |||
<textarea :value="currentMarker.annotation" @input="updateAnnotation"></textarea> | |||
<div> | |||
<label for="editMode">Place point on click </label> | |||
<input id="editMode" type="checkbox" v-model="isEditMode" /> | |||
</div> | |||
</div> | |||
</nav> | |||
</template> | |||
<script setup lang="ts"> | |||
import type { Marker } from '@/types/virages' | |||
import { onMounted, ref, inject, nextTick } from 'vue' | |||
import OpenSeadragon from 'openseadragon' | |||
const props = defineProps<{ markers: Marker[] }>() | |||
const Viewer = inject('Viewer') | |||
const isEditMode = ref<boolean>(false) | |||
const currentMarker = ref<Marker>({} as Marker) | |||
const placePoint = (point, zoom, annotation = '') => { | |||
const overlay = document.createElement('button') | |||
overlay.className = | |||
'w-8 h-8 rounded-full shadow-2xl bg-green-500 bg-opacity-50 hover:bg-green-600 hover:bg-opacity-100 z-index-20 hover:cursor-pointer pointer-events-auto hover:scale-150 transition-all border-2 border-white border-8' | |||
// Sauvegarde les données dans les attributs "data-*" | |||
overlay.setAttribute('data-zoom', zoom) | |||
overlay.setAttribute('data-position-x', point.x) | |||
overlay.setAttribute('data-position-y', point.y) | |||
overlay.setAttribute('data-annotation', annotation) | |||
overlay.addEventListener('click', function (e) { | |||
const zoom = Number(overlay.getAttribute('data-zoom')) | |||
const x = Number(overlay.getAttribute('data-position-x')) | |||
const y = Number(overlay.getAttribute('data-position-y')) | |||
const annotation = overlay.getAttribute('data-annotation') | |||
Viewer.value.viewport.zoomTo(zoom, false) | |||
Viewer.value.viewport.panTo(new OpenSeadragon.Point(x, y)) | |||
currentMarker.value.annotation = annotation | |||
}) | |||
overlay.addEventListener('mouseover', function (e) { | |||
Viewer.value.setMouseNavEnabled(false) | |||
}) | |||
overlay.addEventListener('mouseout', function (e) { | |||
Viewer.value.setMouseNavEnabled(true) | |||
}) | |||
// Injection de l'overlay | |||
Viewer.value.addOverlay({ | |||
element: overlay, | |||
location: point, | |||
}) | |||
} | |||
const placeAPointOnClick = () => { | |||
Viewer.value.addHandler('canvas-click', (e) => { | |||
if (!isEditMode.value) return | |||
const point = Viewer.value.viewport.pointFromPixel(e.position) | |||
const zoom = Viewer.value.viewport.getZoom() | |||
placePoint(point, zoom) | |||
isEditMode.value = false | |||
}) | |||
} | |||
const loadStory = (markers: Marker[]) => { | |||
markers.forEach((marker) => { | |||
placePoint( | |||
new OpenSeadragon.Point(marker.position.x, marker.position.y), | |||
marker.zoom, | |||
marker.annotation, | |||
) | |||
}) | |||
} | |||
onMounted(() => { | |||
nextTick(() => { | |||
Viewer.value.zoomPerClick = true | |||
placeAPointOnClick() | |||
loadStory(props.markers) | |||
}) | |||
}) | |||
</script> |
@@ -1,5 +1,7 @@ | |||
export interface Marker { | |||
id: number | |||
order: number | |||
name: string | |||
position: OpenSeadragon.Point | |||
zoom: number | |||
annotation: string | |||
@@ -6,13 +6,10 @@ import vueDevTools from 'vite-plugin-vue-devtools' | |||
// https://vite.dev/config/ | |||
export default defineConfig({ | |||
plugins: [ | |||
vue(), | |||
vueDevTools(), | |||
], | |||
plugins: [vue(), vueDevTools()], | |||
resolve: { | |||
alias: { | |||
'@': fileURLToPath(new URL('./src', import.meta.url)) | |||
'@': fileURLToPath(new URL('./src', import.meta.url)), | |||
}, | |||
}, | |||
}) |