Files
FHC-Core/public/js/components/Calendar/Base/Grid.js
T
2025-10-17 10:14:47 +02:00

436 lines
11 KiB
JavaScript

import GridLine from './Grid/Line.js';
import GridLineEvent from './Grid/Line/Event.js';
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
export default {
name: "CalendarGrid",
components: {
GridLine,
GridLineEvent
},
directives: {
CalDnd
},
inject: {
originalEvents: "events",
originalBackgrounds: "backgrounds",
dropAllowed: "dropAllowed"
},
provide() {
return {
flipAxis: Vue.computed(() => this.flipAxis),
axisRow: Vue.computed(() => this.axisRow)
};
},
props: {
axisMain: {
type: Array,
required: true,
validator(value) {
return value.every(item => item instanceof luxon.DateTime);
}
},
axisParts: {
type: Array,
required: true,
validator(value) {
return value.every(item =>
item instanceof luxon.Duration
|| Number.isInteger(item)
|| (
(
item.start instanceof luxon.Duration
|| Number.isInteger(item.start)
) && (
item.end instanceof luxon.Duration
|| Number.isInteger(item.end)
)
)
);
}
},
flipAxis: Boolean,
allDayEvents: Boolean,
axisMainCollapsible: Boolean,
snapToGrid: Boolean
},
data() {
return {
dragging: false,
resizeObserver: null,
mutationObserver: null,
userScroll: true
};
},
computed: {
axisRow() {
return this.flipAxis ? 'column' : 'row';
},
axisCol() {
return this.flipAxis ? 'row' : 'column';
},
axisPartsWithBreaks() {
return this.axisParts.reduce((res, tu, index) => {
const start = tu.start || tu;
const end = tu.end;
if (res.length) {
const lastTuEnd = res.pop();
if (Array.isArray(lastTuEnd)) {
res.push({
start: lastTuEnd[0],
end: start,
index: lastTuEnd[1]
});
} else if (lastTuEnd != start) {
// add pause
res.push({
start: lastTuEnd,
end: start
});
}
}
if (!end) {
res.push([start, index]);
} else {
res.push({
start,
end,
index
});
res.push(end);
}
return res;
}, []).slice(0, -1);
},
axisPartsSave() {
if (!this.axisParts[this.axisParts.length - 1].end)
return this.axisParts.slice(0, -1);
return this.axisParts;
},
start() {
return this.axisPartsWithBreaks[0].start;
},
end() {
return this.axisPartsWithBreaks[this.axisPartsWithBreaks.length - 1].end;
},
ends() {
const ends = [];
const partsEnds = this.axisPartsWithBreaks
.filter(p => p.index !== undefined)
.map(p => p.end);
for (var date of this.axisMain)
for (var part of partsEnds)
ends.push(date.plus(part));
return ends;
},
axisMainBorders() {
return this.axisMain.reduce(
(res, curr) => res.concat([curr.plus(this.start), curr.plus(this.end)]),
[]
);
},
eventsAllDay() {
if (!this.allDayEvents)
return [];
return this.mapIntoMainAxis(this.originalEvents.filter(event => event.orig.allDayEvent));
},
eventsNormal() {
if (!this.allDayEvents)
return this.events;
return this.mapIntoMainAxis(this.originalEvents.filter(event => !event.orig.allDayEvent));
},
events() {
return this.mapIntoMainAxis(this.originalEvents);
},
backgrounds() {
return this.mapIntoMainAxis(this.originalBackgrounds);
},
hasValidEvents() {
return this.events.find(e => e.length);
},
styleGridCols() {
let cols = 'repeat(' + this.axisMain.length + ', 1fr)';
if (this.axisMainCollapsible) {
if (this.hasValidEvents)
cols = this.events
.map(e => e.length
? '1fr'
: 'var(--fhc-calendar-axis-collapsible, .5fr)')
.join(' ');
}
return cols;
},
styleGridRows() {
const gridlines = {};
this.axisPartsWithBreaks.forEach(part => {
let ts = part.start.toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts];
if (part.index !== undefined)
gridlines[ts].push('ps_' + part.index);
ts = part.end.toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts];
if (part.index !== undefined)
gridlines[ts].push('pe_' + part.index);
});
this.eventsNormal.forEach((events, mainIndex) => {
let day = this.axisMain[mainIndex];
events.forEach(event => {
if (!event.startsHere && !event.endsHere)
return;
if (event.startsHere) {
let ts = event.start.diff(day).toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts, 'e_' + ts];
}
if (event.endsHere) {
let ts = event.end.diff(day).toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts, 'e_' + ts];
}
});
});
return Object.keys(gridlines).sort((a,b) => parseInt(a)-parseInt(b)).map((start, i, keys) => {
let end = keys[i + 1];
if (!end) {
gridlines[start].push('end');
return '[' + gridlines[start].join(' ') + ']';
}
return '[' + gridlines[start].join(' ') + '] ' + (end - start) + 'fr';
}).join(' ');
}
},
methods: {
mapIntoMainAxis(target) {
const result = Array.from({length: this.axisMain.length}, () => Array());
target.forEach(event => {
const start = event.start || this.axisMainBorders[0].plus(-1);
const end = event.end || this.axisMainBorders[this.axisMainBorders.length - 1].plus(1);
for (var i = 0; i < this.axisMain.length; i++) {
let laneStart = this.axisMainBorders[i * 2];
let laneEnd = this.axisMainBorders[i * 2 + 1];
if (event.orig?.allDayEvent) {
laneStart = laneStart.startOf('day');
laneEnd = laneEnd.endOf('day');
}
if (start < laneEnd && end > laneStart) {
const startsHere = start >= laneStart;
const endsHere = end <= laneEnd;
result[i].push({
...event,
startsHere,
endsHere
});
}
}
});
return result;
},
/* DRAG AND DROP */
getPageTop(el) {
let pageTop = el.offsetTop;
if (el.offsetParent)
pageTop += this.getPageTop(el.offsetParent);
return pageTop;
},
getPageLeft(el) {
let pageLeft = el.offsetLeft;
if (el.offsetParent)
pageLeft += this.getPageLeft(el.offsetParent);
return pageLeft;
},
getTimestampFromMouse(evt, dayTimestamp) {
let mouse, mouseFrac;
if (this.flipAxis) {
mouse = evt.pageX - this.getPageLeft(this.$refs.body) + this.$refs.main.scrollLeft;
mouseFrac = mouse / this.$refs.body.offsetWidth;
} else {
mouse = evt.pageY - this.getPageTop(this.$refs.body) + this.$refs.main.scrollTop;
mouseFrac = mouse / this.$refs.body.offsetHeight;
}
return dayTimestamp + this.start + Math.floor((this.end - this.start) * mouseFrac);
},
/* SCROLLING */
enableAutoScroll() {
if (!this.resizeObserver)
this.resizeObserver = new ResizeObserver(this.scrollToEarliestEvent);
this.resizeObserver.observe(this.$refs.body);
if (!this.mutationObserver)
this.mutationObserver = new MutationObserver(mutations => {
if (mutations.some(m => m.addedNodes.length && [].some.call(m.addedNodes, el => el.matches && el.matches('.fhc-calendar-base-grid-line-event'))))
this.scrollToEarliestEvent();
});
this.mutationObserver.observe(this.$refs.body, {
subtree: true,
childList: true
});
this.scrollToEarliestEvent();
},
disableAutoScroll() {
if (this.resizeObserver)
this.resizeObserver.disconnect();
this.resizeObserver = null;
if (this.mutationObserver)
this.mutationObserver.disconnect();
this.mutationObserver = null;
},
scrollToEarliestEvent() {
const eventElements = this.$refs.scroller.querySelectorAll('.fhc-calendar-base-grid-line-event');
let earliestEventOffset = [0, null];
for (var el of eventElements.values()) {
const top = el.offsetTop;
if (!earliestEventOffset[1] || top < earliestEventOffset[0])
earliestEventOffset = [top, el];
}
this.userScroll = false;
if (earliestEventOffset[1]) {
earliestEventOffset[1].scrollIntoView({ behavior: "smooth" });
} else {
this.$refs.scroller.scrollTo(0, 0);
}
}
},
beforeUnmount() {
this.disableAutoScroll();
},
template: /* html */`
<div
class="fhc-calendar-base-grid"
style="display:grid;width:100%;height:100%;overflow:auto"
:style="'grid-template-' + axisRow + 's:auto' + (allDayEvents ? ' auto ' : ' ') + '1fr;grid-template-' + axisCol + 's:auto ' + styleGridCols"
>
<div
class="grid-header"
style="display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-' + axisCol + ':1/-1'"
>
<div
v-for="(date, index) in axisMain"
:key="index"
class="main-header"
:class="{'collapsed-header': axisMainCollapsible && hasValidEvents && !events[index].length}"
:style="'grid-' + axisCol + ':' + (2+index)"
>
<slot name="main-header" v-bind="{ index, date }" />
</div>
</div>
<div
v-if="allDayEvents"
class="grid-allday"
style="display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-' + axisCol + ':1/-1'"
>
<div
v-for="(events, index) in eventsAllDay"
:key="index"
class="all-day-events"
:style="'grid-' + axisCol + ':' + (2+index)"
>
<grid-line-event
v-for="(event, i) in events"
:key="i"
:event="event"
>
<template v-slot="slot">
<slot name="event" v-bind="slot" />
</template>
</grid-line-event>
</div>
</div>
<div
ref="scroller"
@scrollend="userScroll ? disableAutoScroll() : userScroll = true"
style="display:grid;overflow:auto"
:style="'grid-' + axisCol + ':1/-1;grid-template-' + axisCol + 's:subgrid'"
>
<div
ref="main"
class="grid-main"
style="position:relative;grid-column:1/-1;grid-row:1/-1;display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-template-' + axisRow + 's:' + styleGridRows"
>
<div
v-for="(part, index) in axisPartsSave"
:key="index"
class="part-header"
:style="'grid-' + axisCol + ':1;grid-' + axisRow + ': ps_' + index + '/pe_' + index"
>
<slot name="part-header" v-bind="{ index, part }" />
</div>
<div
ref="body"
class="grid-body"
style="display:grid;grid-template-rows:subgrid;grid-template-columns:subgrid"
:style="'grid-' + axisCol + ':2/-1;grid-' + axisRow + ':1/-1'"
v-cal-dnd:dropcage
@calendar-dragenter="dragging = true"
@calendar-dragleave="dragging = false"
@dragover="dropAllowed ? $event.preventDefault() : null"
>
<template
v-for="(date, index) in axisMain"
:key="index"
>
<div
v-for="(part, i) in axisPartsSave"
:key="i"
class="part-body"
style="position:relative"
:style="'grid-' + axisCol + ':' + (1+index) + ';grid-' + axisRow + ':ps_' + i + '/pe_' + i"
>
<slot name="part-body" v-bind="{ index, part }" />
<div
v-if="snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
v-cal-dnd:dropzone.once="{date: date.plus(part.start || part), ends: ends.slice(ends.findIndex(end => end > date))}"
></div>
</div>
<grid-line
:start="date.plus(start)"
:end="date.plus(end)"
:date="date"
:events="eventsNormal[index]"
:backgrounds="backgrounds[index]"
style="position:relative"
:style="'grid-' + axisRow + ':1/-1;grid-' + axisCol + ':' + (1+index)"
>
<template #event="slot">
<slot name="event" v-bind="slot" />
</template>
<template #dropzone>
<div
v-if="!snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
v-cal-dnd:dropzone="evt => getTimestampFromMouse(evt, date)"
></div>
</template>
</grid-line>
</template>
</div>
</div>
</div>
</div>
`
}