Browse Source

feat: editor v 0.1

master
valere 3 months ago
parent
commit
83f4ab4a68
12 changed files with 429 additions and 107 deletions
  1. +25
    -1
      package-lock.json
  2. +4
    -2
      package.json
  3. +12
    -7
      src/App.vue
  4. +12
    -6
      src/assets/story.json
  5. +0
    -0
      src/components/StoryList.vue
  6. +17
    -0
      src/components/tools/StoryEditor.md
  7. +198
    -0
      src/components/tools/StoryEditor.vue
  8. +83
    -0
      src/components/tools/StoryPlayer.vue
  9. +74
    -0
      src/components/tools/TestSortable.vue
  10. +0
    -86
      src/components/tools/ZoomPoint.vue
  11. +2
    -0
      src/types/virages.d.ts
  12. +2
    -5
      vite.config.ts

+ 25
- 1
package-lock.json View File

@@ -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
- 2
package.json View File

@@ -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",


+ 12
- 7
src/App.vue View File

@@ -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,
},
})
}



+ 12
- 6
src/assets/story.json View File

@@ -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
src/components/StoryList.vue View File


+ 17
- 0
src/components/tools/StoryEditor.md View File

@@ -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

+ 198
- 0
src/components/tools/StoryEditor.vue View File

@@ -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>

+ 83
- 0
src/components/tools/StoryPlayer.vue View File

@@ -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>

+ 74
- 0
src/components/tools/TestSortable.vue View File

@@ -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>

+ 0
- 86
src/components/tools/ZoomPoint.vue View File

@@ -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>

+ 2
- 0
src/types/virages.d.ts View File

@@ -1,5 +1,7 @@
export interface Marker {
id: number
order: number
name: string
position: OpenSeadragon.Point
zoom: number
annotation: string


+ 2
- 5
vite.config.ts View File

@@ -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)),
},
},
})

Loading…
Cancel
Save