feat(exercises): bump version to 0.8.64 and enhance media handling
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
Test Suite / pytest-backend (pull_request) Successful in 23s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Successful in 23s
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
Test Suite / pytest-backend (pull_request) Successful in 23s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Successful in 23s
- Incremented application version to 0.8.64 and updated changelog with new features. - Improved media handling in the Rich Text Editor with auto-scrolling during drag-and-drop. - Added new CSS styles for video thumbnails and enhanced layout for media items. - Removed deprecated `ExerciseAttachmentMediaStrip` from the ExerciseFullContent component. - Updated ExerciseFormPage to manage form dirty state and prevent data loss on navigation.
This commit is contained in:
parent
337f29401b
commit
5cf775c920
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.63"
|
||||
APP_VERSION = "0.8.64"
|
||||
BUILD_DATE = "2026-05-08"
|
||||
DB_SCHEMA_VERSION = "20260508049"
|
||||
|
||||
|
|
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.64",
|
||||
"date": "2026-05-08",
|
||||
"changes": [
|
||||
"Übung bearbeiten: Auto-Scroll beim Drag von Medien zu Textfeldern; Medien-Kacheln mehrspaltig; Sektion/Ablauf-Zuordnung an Medien entfernt (nur noch Titel bearbeiten); Picker: Video-Vorschau-Frame; Katalogvorschau ohne Anhangs-Medienliste; Ansehen mit Speichern-Hinweis + Zurück zur Bearbeitung von der Ansicht",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.63",
|
||||
"date": "2026-05-08",
|
||||
|
|
|
|||
|
|
@ -4018,6 +4018,13 @@ a.analysis-split__nav-item {
|
|||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.rte-inline-asset-tile__thumb-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
.rte-inline-asset-tile__thumb-fallback {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
|
|
@ -4066,9 +4073,10 @@ a.analysis-split__nav-item {
|
|||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 14px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.exercise-edit-media-strip__item {
|
||||
display: flex;
|
||||
|
|
@ -4129,15 +4137,10 @@ a.analysis-split__nav-item {
|
|||
}
|
||||
.exercise-edit-media-strip__toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(120px, 160px);
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.exercise-edit-media-strip__toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.exercise-edit-media-strip__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||
import ExerciseAttachmentMediaStrip from './ExerciseAttachmentMediaStrip'
|
||||
|
||||
function TagRow({ exercise }) {
|
||||
const tags = []
|
||||
|
|
@ -118,7 +117,6 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
|||
<ExerciseRichTextBlock html={exercise.execution} exerciseId={resolvedId} media={exercise.media} />
|
||||
</section>
|
||||
)}
|
||||
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={resolvedId} />
|
||||
{exercise.trainer_notes && (
|
||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,41 @@ import {
|
|||
} from '../constants/inlineExerciseMedia'
|
||||
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
|
||||
|
||||
function RtePickerAssetThumb({ asset }) {
|
||||
const id = asset.id
|
||||
const src = resolveMediaAssetFileUrl(id)
|
||||
const mt = (asset.mime_type || '').toLowerCase()
|
||||
if (mt.startsWith('image/') && src) {
|
||||
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
|
||||
}
|
||||
if (mt.startsWith('video/') && src) {
|
||||
return (
|
||||
<video
|
||||
key={`v-${id}`}
|
||||
className="rte-inline-asset-tile__thumb-video"
|
||||
src={src}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onLoadedMetadata={(e) => {
|
||||
try {
|
||||
const el = e.currentTarget
|
||||
const d = el.duration
|
||||
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const nameLow = String(asset.original_filename || '').toLowerCase()
|
||||
if (mt.includes('pdf') || nameLow.endsWith('.pdf')) {
|
||||
return <span className="rte-inline-asset-tile__thumb-fallback">PDF</span>
|
||||
}
|
||||
return <span className="rte-inline-asset-tile__thumb-fallback">Datei</span>
|
||||
}
|
||||
|
||||
/** MIME/Dateiname → Übungs-media_type */
|
||||
function inferExerciseMediaType(file) {
|
||||
if (!file) return 'image'
|
||||
|
|
@ -263,9 +298,6 @@ export default function ExerciseInlineFileMediaModal({
|
|||
const selected = selectedAssetId === id
|
||||
const label = it.original_filename || it.copyright_notice || `Archiv #${id}`
|
||||
const linked = assetToExerciseMedia.has(Number(id))
|
||||
const src = resolveMediaAssetFileUrl(id)
|
||||
const isImg = (it.mime_type || '').startsWith('image/')
|
||||
const isVid = (it.mime_type || '').startsWith('video/')
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
|
|
@ -275,13 +307,7 @@ export default function ExerciseInlineFileMediaModal({
|
|||
disabled={busy}
|
||||
>
|
||||
<div className="rte-inline-asset-tile__thumb" aria-hidden>
|
||||
{isImg && src ? (
|
||||
<img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
|
||||
) : isVid ? (
|
||||
<span className="rte-inline-asset-tile__thumb-fallback">▶ Video</span>
|
||||
) : (
|
||||
<span className="rte-inline-asset-tile__thumb-fallback">PDF / Datei</span>
|
||||
)}
|
||||
<RtePickerAssetThumb asset={it} />
|
||||
</div>
|
||||
{linked ? (
|
||||
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
||||
parseExerciseMediaDragPayload,
|
||||
} from '../utils/exerciseInlineMediaRefs'
|
||||
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
|
||||
|
||||
function exec(cmd, value = null) {
|
||||
try {
|
||||
|
|
@ -265,6 +266,7 @@ export default function RichTextEditor({
|
|||
if (types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
autoScrollForDragNearEdges(e)
|
||||
}
|
||||
},
|
||||
[showInlineToolbar],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
||||
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
||||
|
|
@ -53,6 +53,7 @@ function metaParts(exercise) {
|
|||
function ExerciseDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [exercise, setExercise] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -104,16 +105,19 @@ function ExerciseDetailPage() {
|
|||
if (!exercise) return null
|
||||
|
||||
const meta = metaParts(exercise)
|
||||
const fromExerciseEdit = location.state?.fromExerciseEdit === true
|
||||
|
||||
return (
|
||||
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
|
||||
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
|
||||
← Übersicht
|
||||
</button>
|
||||
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
|
||||
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
|
||||
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card exercise-detail-section">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
||||
buildExerciseMediaDragPayload,
|
||||
} from '../utils/exerciseInlineMediaRefs'
|
||||
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
|
|
@ -373,6 +374,7 @@ function ExerciseFormPage() {
|
|||
const [mediaList, setMediaList] = useState([])
|
||||
const [loading, setLoading] = useState(!!isEdit)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formDirty, setFormDirty] = useState(false)
|
||||
const [skillPick, setSkillPick] = useState('')
|
||||
const [variants, setVariants] = useState([])
|
||||
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
|
||||
|
|
@ -385,7 +387,6 @@ function ExerciseFormPage() {
|
|||
const [mediaSavingId, setMediaSavingId] = useState(null)
|
||||
const [archiveOpen, setArchiveOpen] = useState(false)
|
||||
const [archiveQ, setArchiveQ] = useState('')
|
||||
const [archiveCtx, setArchiveCtx] = useState('ablauf')
|
||||
const [archiveLoading, setArchiveLoading] = useState(false)
|
||||
const [archiveItems, setArchiveItems] = useState([])
|
||||
const [archiveError, setArchiveError] = useState(null)
|
||||
|
|
@ -396,12 +397,32 @@ function ExerciseFormPage() {
|
|||
for (const m of mediaList) {
|
||||
next[m.id] = {
|
||||
title: m.title || '',
|
||||
context: m.context || 'ablauf',
|
||||
}
|
||||
}
|
||||
setMediaFields(next)
|
||||
}, [mediaList])
|
||||
|
||||
useEffect(() => {
|
||||
const onDragOverDoc = (e) => {
|
||||
const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : []
|
||||
if (!types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) return
|
||||
e.preventDefault()
|
||||
autoScrollForDragNearEdges(e)
|
||||
}
|
||||
document.addEventListener('dragover', onDragOverDoc)
|
||||
return () => document.removeEventListener('dragover', onDragOverDoc)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formDirty) return undefined
|
||||
const warn = (ev) => {
|
||||
ev.preventDefault()
|
||||
ev.returnValue = ''
|
||||
}
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
}, [formDirty])
|
||||
|
||||
useEffect(() => {
|
||||
if (!archiveOpen) return undefined
|
||||
let cancelled = false
|
||||
|
|
@ -466,6 +487,7 @@ function ExerciseFormPage() {
|
|||
setVariants([])
|
||||
setVariantDraft(emptyVariantDraft())
|
||||
setVariantEditSelection(null)
|
||||
setFormDirty(false)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -480,6 +502,7 @@ function ExerciseFormPage() {
|
|||
setVariants((exercise.variants || []).map(apiVariantToRow))
|
||||
setVariantDraft(emptyVariantDraft())
|
||||
setVariantEditSelection(null)
|
||||
setFormDirty(false)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
alert(err.message || 'Übung nicht ladbar')
|
||||
|
|
@ -509,6 +532,7 @@ function ExerciseFormPage() {
|
|||
}, [variantEditSelection])
|
||||
|
||||
const updateFormField = (field, value) => {
|
||||
setFormDirty(true)
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
|
|
@ -646,6 +670,7 @@ function ExerciseFormPage() {
|
|||
const ex = await api.getExercise(exerciseId)
|
||||
setMediaList(ex.media || [])
|
||||
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||
setFormDirty(false)
|
||||
alert('Gespeichert.')
|
||||
} else {
|
||||
const created = await api.createExercise(payload)
|
||||
|
|
@ -669,7 +694,7 @@ function ExerciseFormPage() {
|
|||
try {
|
||||
await api.attachExerciseMediaFromAsset(exerciseId, {
|
||||
media_asset_id: assetId,
|
||||
context: archiveCtx,
|
||||
context: 'ablauf',
|
||||
title: '',
|
||||
description: '',
|
||||
is_primary: false,
|
||||
|
|
@ -689,7 +714,8 @@ function ExerciseFormPage() {
|
|||
const handleDeleteMedia = async (mid) => {
|
||||
if (
|
||||
!confirm(
|
||||
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
|
||||
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.\n\n' +
|
||||
'Hinweis: Wenn dieser Eintrag noch als Platzhalter im Fließtext steht, zeigt die Vorschau [Medium nicht verfügbar] oder das Speichern der Übung schlägt fehl, bis der Platzhalter entfernt ist.',
|
||||
)
|
||||
) {
|
||||
return
|
||||
|
|
@ -740,7 +766,6 @@ function ExerciseFormPage() {
|
|||
try {
|
||||
await api.updateExerciseMedia(exerciseId, mid, {
|
||||
title: fld.title.trim() || null,
|
||||
context: fld.context,
|
||||
})
|
||||
await refreshMedia()
|
||||
} catch (e) {
|
||||
|
|
@ -757,6 +782,7 @@ function ExerciseFormPage() {
|
|||
}
|
||||
|
||||
const updateVariantField = (id, patch) => {
|
||||
setFormDirty(true)
|
||||
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
|
||||
}
|
||||
|
||||
|
|
@ -860,7 +886,18 @@ function ExerciseFormPage() {
|
|||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginLeft: '8px' }}
|
||||
onClick={() => navigate(`/exercises/${exerciseId}`)}
|
||||
onClick={() => {
|
||||
if (
|
||||
formDirty &&
|
||||
!window.confirm(
|
||||
'Es gibt noch nicht über „Speichern“ gesicherte Änderungen (Texte, Zuordnungen, …).\n\n' +
|
||||
'Zur Ansicht wechseln und diese Änderungen verwerfen?',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })
|
||||
}}
|
||||
>
|
||||
Ansehen
|
||||
</button>
|
||||
|
|
@ -1236,7 +1273,10 @@ function ExerciseFormPage() {
|
|||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
|
||||
<ExerciseVariantFields
|
||||
row={variantDraft}
|
||||
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
|
||||
onPatch={(patch) => {
|
||||
setFormDirty(true)
|
||||
setVariantDraft((d) => ({ ...d, ...patch }))
|
||||
}}
|
||||
prerequisiteOthers={variants}
|
||||
rteMinHeight="110px"
|
||||
inlineExerciseId={isEdit ? exerciseId : null}
|
||||
|
|
@ -1417,37 +1457,17 @@ function ExerciseFormPage() {
|
|||
<input
|
||||
type="text"
|
||||
className="form-input exercise-edit-media-strip__title"
|
||||
placeholder="Titel"
|
||||
placeholder="Titel (wird in der Vorschau und im Platzhalter genutzt)"
|
||||
value={(mediaFields[m.id] || {}).title ?? ''}
|
||||
onChange={(e) =>
|
||||
setMediaFields((prev) => ({
|
||||
...prev,
|
||||
[m.id]: {
|
||||
...(prev[m.id] || {}),
|
||||
title: e.target.value,
|
||||
context: (prev[m.id] || {}).context || 'ablauf',
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="form-input exercise-edit-media-strip__ctx"
|
||||
value={(mediaFields[m.id] || {}).context || 'ablauf'}
|
||||
onChange={(e) =>
|
||||
setMediaFields((prev) => ({
|
||||
...prev,
|
||||
[m.id]: {
|
||||
...(prev[m.id] || {}),
|
||||
title: (prev[m.id] || {}).title ?? '',
|
||||
context: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="ablauf">Ablauf</option>
|
||||
<option value="detail">Detail</option>
|
||||
<option value="trainer_hint">Trainer-Hinweis</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="exercise-edit-media-strip__actions">
|
||||
{mediaList.length > 1 && (
|
||||
|
|
@ -1498,6 +1518,10 @@ function ExerciseFormPage() {
|
|||
})}
|
||||
</ul>
|
||||
)}
|
||||
<p style={{ color: 'var(--text3)', fontSize: '12px', marginTop: mediaList.length ? '12px' : 0 }}>
|
||||
Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über
|
||||
Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
|
||||
</p>
|
||||
{archiveOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
|
|
@ -1534,16 +1558,6 @@ function ExerciseFormPage() {
|
|||
onChange={(e) => setArchiveQ(e.target.value)}
|
||||
style={{ marginBottom: '8px' }}
|
||||
/>
|
||||
<select
|
||||
className="form-input"
|
||||
value={archiveCtx}
|
||||
onChange={(e) => setArchiveCtx(e.target.value)}
|
||||
style={{ marginBottom: '12px' }}
|
||||
>
|
||||
<option value="ablauf">Sektion: Ablauf</option>
|
||||
<option value="detail">Sektion: Detail</option>
|
||||
<option value="trainer_hint">Sektion: Trainer-Hinweis</option>
|
||||
</select>
|
||||
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden…</p>}
|
||||
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
|
||||
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
|
||||
|
|
|
|||
66
frontend/src/utils/dragAutoScroll.js
Normal file
66
frontend/src/utils/dragAutoScroll.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Während eines Drags automatischen Bildlauf auslösen (Viewport + scrollbare Bereiche unter dem Cursor).
|
||||
* @param {DragEvent} e
|
||||
* @param {{ edgePx?: number, scrollStep?: number }} [opts]
|
||||
*/
|
||||
export function autoScrollForDragNearEdges(e, opts = {}) {
|
||||
const edge = opts.edgePx ?? 80
|
||||
const step = opts.scrollStep ?? Math.max(24, Math.round(window.innerHeight * 0.05))
|
||||
const { clientX, clientY } = e
|
||||
|
||||
const vh = window.innerHeight || 0
|
||||
const vw = window.innerWidth || 0
|
||||
|
||||
let sy =
|
||||
clientY < edge ? -step : vh > 0 && clientY > vh - edge ? step : 0
|
||||
let sx =
|
||||
clientX < edge ? -step : vw > 0 && clientX > vw - edge ? step : 0
|
||||
|
||||
if (sy !== 0) window.scrollBy(0, sy)
|
||||
if (sx !== 0) window.scrollBy(sx, 0)
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
let top = /** @type {HTMLElement|null} */ (document.elementFromPoint(clientX, clientY))
|
||||
|
||||
/** @type {Set<HTMLElement>} */
|
||||
const done = new Set()
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
let walk = top
|
||||
const innerEdge = Math.min(edge, 40)
|
||||
|
||||
while (walk && walk !== document.body) {
|
||||
if (!(walk instanceof HTMLElement)) break
|
||||
|
||||
const cs = window.getComputedStyle(walk)
|
||||
const canY =
|
||||
walk.scrollHeight - walk.clientHeight > 6 &&
|
||||
(cs.overflowY === 'auto' || cs.overflowY === 'scroll')
|
||||
const canX =
|
||||
walk.scrollWidth - walk.clientWidth > 6 &&
|
||||
(cs.overflowX === 'auto' || cs.overflowX === 'scroll')
|
||||
|
||||
if ((canY || canX) && !done.has(walk)) {
|
||||
done.add(walk)
|
||||
const rect = walk.getBoundingClientRect()
|
||||
|
||||
const relY = clientY - rect.top
|
||||
const relX = clientX - rect.left
|
||||
|
||||
if (canY) {
|
||||
if (relY < innerEdge && walk.scrollTop > 0) walk.scrollTop -= step
|
||||
else if (rect.height - relY < innerEdge && walk.scrollTop < walk.scrollHeight - walk.clientHeight) {
|
||||
walk.scrollTop += step
|
||||
}
|
||||
}
|
||||
if (canX) {
|
||||
if (relX < innerEdge && walk.scrollLeft > 0) walk.scrollLeft -= step
|
||||
else if (rect.width - relX < innerEdge && walk.scrollLeft < walk.scrollWidth - walk.clientWidth) {
|
||||
walk.scrollLeft += step
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk = walk.parentElement
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user