+
[
+ this.getStudentAjaxId(student),
+ ApiStvGroups.add(
+ student.id,
+ data.gruppe_kurzbz,
+ this.currentSemester
+ )
+ ]);
+ } else {
+ const { semester, verband, gruppe } = data;
+ const params = { semester, verband, gruppe };
+ endpoint = students.map(student => [
+ this.getStudentAjaxId(student),
+ ApiStvDetails.saveStudent(
+ student.id,
+ this.currentSemester,
+ params
+ )
+ ]);
+ }
+
+ return this.$api
+ .call(endpoint)
+ .then(this.$reloadList)
+ .catch(this.$fhcAlert.handleSystemError);
}
},
mounted() {
@@ -268,10 +331,21 @@ export default {
- {{node.data.name}}
+ {{ node.data.name }}
+
+
+ {{ node.data.name }}
diff --git a/public/js/directives/dragClick.js b/public/js/directives/dragClick.js
new file mode 100644
index 000000000..aeeafb818
--- /dev/null
+++ b/public/js/directives/dragClick.js
@@ -0,0 +1,101 @@
+export default {
+ mounted(el, binding) {
+ const delay = parseInt(binding.arg) || 300;
+
+ let timeout = null;
+ function startCountdown() {
+ timeout = window.setTimeout(binding.value, delay);
+ }
+ function stopCountdown() {
+ if (timeout)
+ window.clearTimeout(timeout);
+ timeout = null;
+ }
+
+ function onEnter(evt) {
+ let lastTarget = evt.target;
+ let lastX = evt.offsetX;
+ let lastY = evt.offsetY;
+
+ el.addEventListener('dragover', evt => {
+ if (lastX != evt.offsetX || lastY != evt.offsetY || lastTarget != evt.target) {
+ // moved
+ lastTarget = evt.target;
+ lastX = evt.offsetX;
+ lastY = evt.offsetY;
+
+ stopCountdown();
+ startCountdown();
+ }
+ });
+
+ startCountdown();
+ }
+ function onLeave() {
+ stopCountdown();
+ }
+
+ // NOTE(chris): add save dragenter and dragleave events
+ // that won't fire when hovering over child elements
+
+ let skipLeave = false;
+ let skipLeaveParent = true;
+
+ function init(evt) {
+ skipLeave = false;
+ skipLeaveParent = true;
+ // add global listeners
+ window.addEventListener('dragenter', globalDragenter, true);
+ window.addEventListener('dragleave', globalDragleave, true);
+ window.addEventListener('drop', globalDrop, true);
+ // call enter
+ onEnter(evt);
+ // remove self
+ el.removeEventListener('dragenter', init);
+ }
+
+ function cleanup() {
+ // remove global listeners
+ window.removeEventListener('dragenter', globalDragenter, true);
+ window.removeEventListener('dragleave', globalDragleave, true);
+ window.removeEventListener('drop', globalDrop, true);
+ // call leave
+ onLeave();
+ // add init
+ el.addEventListener('dragenter', init);
+ }
+
+ function globalDragenter(evt) {
+ skipLeaveParent = false;
+ if (el != evt.target && !el.contains(evt.target)) {
+ cleanup();
+ } else {
+ skipLeave = true;
+ }
+ }
+ function globalDragleave(evt) {
+ if (el != evt.target && !el.contains(evt.target)) {
+ if (skipLeaveParent) {
+ skipLeaveParent = false;
+ return;
+ }
+ } else {
+ if (skipLeave) {
+ skipLeave = false;
+ return;
+ }
+ }
+ cleanup();
+ }
+ function globalDrop(evt) {
+ cleanup();
+ }
+
+ el.addEventListener('dragenter', init);
+ el.initFunc = init;
+ },
+ beforeUnmount(el) {
+ el.removeEventListener('dragenter', el.initFunc);
+ delete el.initFunc;
+ }
+}
diff --git a/public/js/directives/draggable.js b/public/js/directives/draggable.js
new file mode 100644
index 000000000..c72ad49f2
--- /dev/null
+++ b/public/js/directives/draggable.js
@@ -0,0 +1,97 @@
+import { setTransferData, convertToValidDragObject, dragendWorker } from '../helpers/DragAndDrop.js';
+
+const EFFECTS = [
+ 'none',
+ 'copy',
+ 'copyLink',
+ 'copyMove',
+ 'link',
+ 'linkMove',
+ 'move',
+ 'all',
+ 'uninitialized'
+];
+
+export default {
+ mounted(el, binding, vnode) {
+ updateValue(el, binding.value);
+ updateEffectAllowed(el, binding.arg);
+
+ // if modifier capture is set we assume it's on a parent element
+ // i.e: for dragging multiple elements
+ // otherwise set draggable attribute
+ if (!binding.modifiers.capture) {
+ el.draggable = true;
+ }
+
+ el.addEventListener('dragstart', evt => {
+ const value = el.dataset.fhcDraggableValue;
+ if (value) {
+ setTransferData(evt, JSON.parse(value), true);
+ if (el.dataset.fhcEffectAllowed)
+ evt.dataTransfer.effectAllowed = el.dataset.fhcEffectAllowed;
+ blockDragend();
+ } else {
+ evt.preventDefault();
+ }
+ }, binding.modifiers.capture);
+
+ let id;
+ let evt = null;
+ let dataTransfer = null;
+ function blockDragend() {
+ id = el.dataset.fhcDraggableValue;
+ dragendWorker.port.postMessage(['init', id]);
+ window.addEventListener('dragend', blockHandler, true);
+ }
+ function unblockDragend(e) {
+ if (e) {
+ evt = e;
+ dataTransfer = e.dataTransfer;
+ }
+ window.removeEventListener('dragend', blockHandler, true);
+ }
+
+ function blockHandler(evt) {
+ if (evt.dataTransfer.dropEffect == 'none')
+ return unblockDragend();
+ unblockDragend(evt);
+ evt.stopPropagation();
+ dragendWorker.port.postMessage(['request']);
+ }
+
+ dragendWorker.port.onmessage = e => {
+ const [ func, ...args ] = e.data;
+ if (func != 'fire')
+ return;
+ const [ targetId ] = args;
+ if (targetId != id)
+ return;
+ if (evt === null)
+ unblockDragend();
+ else
+ el.dispatchEvent(evt);
+ }
+ },
+ updated(el, binding) {
+ updateValue(el, binding.value);
+ updateEffectAllowed(el, binding.arg);
+ }
+}
+
+// Helper functions
+function updateValue(el, value) {
+ value = convertToValidDragObject(value);
+ if (value) {
+ el.dataset.fhcDraggableValue = JSON.stringify(value);
+ } else if (el.dataset.fhcDraggableValue) {
+ delete el.dataset.fhcDraggableValue;
+ }
+}
+function updateEffectAllowed(el, effectAllowed) {
+ if (effectAllowed && EFFECTS.includes(effectAllowed)) {
+ el.dataset.fhcEffectAllowed = effectAllowed;
+ } else if (el.dataset.fhcEffectAllowed) {
+ delete el.dataset.fhcEffectAllowed;
+ }
+}
diff --git a/public/js/directives/drop.js b/public/js/directives/drop.js
new file mode 100644
index 000000000..12253386c
--- /dev/null
+++ b/public/js/directives/drop.js
@@ -0,0 +1,57 @@
+import { getValidTransferData, eventHasTypes, dragendWorker } from '../helpers/DragAndDrop.js';
+
+const EFFECTS = [
+ 'move',
+ 'copy',
+ 'link',
+ 'none'
+];
+
+let id = 0;
+
+export default {
+ mounted(el, binding, vnode) {
+ const allowedTypes = Object.keys(binding.modifiers);
+ allowedTypes.forEach(type => {
+ if (type.substr(-11) == '-collection') {
+ const singleType = type.substr(0, type.length-11);
+ if (!allowedTypes.includes(singleType))
+ allowedTypes.push(singleType);
+ }
+ });
+
+ const strict = binding.arg.match(/(strict-|-strict)/);
+ const arg = binding.arg.replace(/(strict-|-strict)/, '');
+ const effect = EFFECTS.includes(arg) ? arg : null;
+
+ let allowed = false;
+
+ el.addEventListener('dragenter', evt => {
+ allowed = eventHasTypes(evt, allowedTypes, strict);
+ if (allowed)
+ evt.preventDefault();
+ });
+ el.addEventListener('dragover', evt => {
+ if (allowed) {
+ evt.preventDefault();
+ if (effect)
+ evt.dataTransfer.dropEffect = effect;
+ }
+ });
+ el.addEventListener('drop', evt => {
+ let result = getValidTransferData(evt, allowedTypes, strict);
+ if (!Array.isArray(result) && !binding.modifiers[result.type] && allowedTypes.includes(result.type + '-collection'))
+ result = [result];
+
+ const res = binding.value(evt, result);
+ if (res instanceof Promise) {
+ const localId = id++;
+ dragendWorker.port.postMessage(['block', localId]);
+ res.then(r => {
+ dragendWorker.port.postMessage(['unblock', localId]);
+ return r;
+ });
+ }
+ });
+ }
+}
diff --git a/public/js/helpers/DragAndDrop.js b/public/js/helpers/DragAndDrop.js
index 1160400f7..be068300c 100644
--- a/public/js/helpers/DragAndDrop.js
+++ b/public/js/helpers/DragAndDrop.js
@@ -1,67 +1,297 @@
/**
* TODO(chris): This is only a prototype!!!
*/
-const DragAndDrop = {
- TYPE_LE: "lehreinheit",
- TYPE_VEVENT: "vevent",
- getValidTransferData(event, allowedTypes) {
- const json = event.dataTransfer.getData('text');
- let obj;
- try {
- obj = JSON.parse(json);
- if (!obj.type)
- return null;
- if (allowedTypes && !allowedTypes.includes(obj.type))
- return null;
- } catch (error) {
- return null;
- }
- return obj;
+const dragendWorker = new SharedWorker(new URL("../sharedworkers/dragend.js", import.meta.url));
+
+const TYPE_DEFINITION = {
+ lehreinheit: {
+ id: "lehreinheit_id",
+ dragIcon: "fa-solid fa-chalkboard-user",
+ extras: [
+ "stundenblockung"
+ ]
},
- isValidTransferData(event, allowedTypes) {
- return this.getValidTransferData(event, allowedTypes) ? true : false;
+ vevent: {
+ id: "uid",
+ dragIcon: "fa-solid fa-calendar",
+ extras: [
+ "dtstart",
+ "dtend",
+ "summary"
+ ]
},
- getTransferData(event) {
- const json = event.dataTransfer.getData('text');
- return JSON.parse(json);
+ person: {
+ id: "person_id",
+ dragIcon: "fa-solid fa-user"
},
- setTransferData(event, data) {
- switch (data.type) {
- case DragAndDrop.TYPE_LE:
- data = DragAndDrop.fromLe(data);
- break;
- default:
- if (data.dtstart && data.dtend && data.uid && data.summary) {
- data = DragAndDrop.fromVEvent(data);
- break;
- }
- return false; // No type found => abort
- }
-
- event.dataTransfer.setData('text', JSON.stringify(data));
- return true;
+ student: {
+ id: "student_uid",
+ dragIcon: "fa-solid fa-user-graduate"
},
- fromLe(data) {
- const {
- type = DragAndDrop.TYPE_LE,
- lehreinheit_id: id,
- stundenblockung
- } = data;
-
- return { type, id, stundenblockung };
- },
- fromVEvent(data) {
- const {
- type = DragAndDrop.TYPE_VEVENT,
- uid: id,
- dtstart,
- dtend,
- summary
- } = data;
-
- return { type, id, dtstart, dtend, summary };
+ prestudent: {
+ id: "prestudent_id",
+ dragIcon: "fa-solid fa-user-graduate text-muted"
}
+ // TODO: IMPLEMENT OTHER TYPES
};
-export default DragAndDrop;
+const VALID_TYPES = Object.keys(TYPE_DEFINITION);
+
+const TYPE_CONSTANTS = Object.keys(TYPE_DEFINITION).reduce((res, type) => {
+ res['TYPE_' + type.toUpperCase()] = type;
+ return res;
+}, {});
+
+function isValidDragObject(value) {
+ if (!value)
+ return false;
+ if (Array.isArray(value))
+ return value.every(isValidDragObject);
+ if (!value.type)
+ return false;
+
+ if (value.type.substr(-11) == '-collection') {
+ if (!value.hasOwnProperty('values'))
+ return false;
+
+ if (!VALID_TYPES.includes(value.type.substr(0, value.type.length-11)))
+ return false;
+ } else {
+ if (!value.hasOwnProperty('id'))
+ return false;
+
+ if (!VALID_TYPES.includes(value.type))
+ return false;
+
+ if (TYPE_DEFINITION[value.type].extras) {
+ if (!TYPE_DEFINITION[value.type].extras.every(extra => value.hasOwnProperty(extra)))
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function getValidTransferData(event, allowedTypes, strict) {
+ let obj = null;
+
+ try {
+ obj = getTransferData(event, strict);
+ if (!obj)
+ return null;
+
+ if (!strict && Array.isArray(obj)) {
+ obj = obj.filter(isValidDragObject);
+ if (!obj.length)
+ return null;
+ } else if (!isValidDragObject(obj))
+ return null;
+
+ if (allowedTypes && allowedTypes.length) {
+ if (Array.isArray(obj)) {
+ if (strict && !obj.every(v => allowedTypes.includes(v.type))) {
+ return null;
+ } else if (!strict) {
+ obj = obj.filter(v => allowedTypes.includes(v.type));
+ if (!obj.length)
+ return null;
+ }
+ } else if (!allowedTypes.includes(obj.type)) {
+ return null;
+ }
+ }
+ } catch(error) {
+ return null;
+ }
+
+ if (Array.isArray(obj) && obj.length == 1)
+ return obj.find(Boolean);
+
+ return obj;
+}
+
+function isValidTransferData(event, allowedTypes, strict) {
+ return getValidTransferData(event, allowedTypes, strict) ? true : false;
+}
+
+function getTransferData(event, strict) {
+ const result = [];
+
+ for (const type of event.dataTransfer.types) {
+ if (type.substr(0, 4) != 'fhc/') {
+ if (strict)
+ return null;
+ continue;
+ }
+ let base_type = type.substr(4);
+ let collection = false;
+ if (base_type.substr(-11) == '-collection') {
+ base_type = base_type.substr(0, base_type.length-11);
+ collection = true;
+ }
+ if (!VALID_TYPES.includes(base_type)) {
+ if (strict)
+ return null;
+ continue;
+ }
+ let data = JSON.parse(event.dataTransfer.getData(type));
+ if (collection)
+ result.push(...data.values);
+ else
+ result.push(data);
+ }
+
+ if (!result.length)
+ return null;
+
+ if (result.length == 1)
+ return result[0];
+
+ return result;
+}
+
+function convertToValidDragObject(data, strict) {
+ if (Array.isArray(data)) {
+ const converted = data.map(convertToValidDragObject).filter(Boolean);
+ if (!converted.length)
+ return undefined;
+ if (strict && converted.length != data.length)
+ return undefined;
+
+ const sorted = converted.reduce((res, item) => {
+ if (!res[item.type])
+ res[item.type] = [];
+ res[item.type].push(item);
+ return res;
+ }, {});
+
+ return Object.entries(sorted).map(([type, values]) => {
+ if (values.length > 1) {
+ return {
+ type: type + '-collection',
+ values
+ };
+ }
+ return values[0];
+ });
+ }
+
+ if (data.hasOwnProperty('type') && isValidDragObject(data)) {
+ return data;
+ }
+
+ const found = Object.entries(TYPE_DEFINITION).find(([type, typedef]) => {
+ if (!data.hasOwnProperty(typedef.id))
+ return false;
+ if (typedef.extras) {
+ if (!typedef.extras.every(extra => data.hasOwnProperty("extra")))
+ return false;
+ }
+ return true;
+ });
+
+ if (!found) {
+ return undefined;
+ }
+
+ const [ type, typedef ] = found;
+
+ const newData = {};
+ newData.type = type;
+ newData.id = data[typedef.id];
+ if (typedef.extras)
+ typedef.extras.forEach(extra => newData[extras] = data[extra]);
+
+ return newData;
+}
+
+function setTransferData(event, validDragObject, setDragImage = false) {
+ if (setDragImage) {
+ const dragItems = Array.isArray(validDragObject) ? validDragObject : [ validDragObject ];
+ const dragElements = dragItems.map(item => {
+ const icon = document.createElement('i');
+ const label = document.createElement('span');
+ const iconContainer = document.createElement('span');
+
+ iconContainer.className = 'btn btn-outline-dark bg-light';
+ label.className = 'small';
+
+ if (TYPE_DEFINITION[item.type]) {
+ icon.className = TYPE_DEFINITION[item.type].dragIcon || 'fa-solid fa-question';
+ label.textContent = item.id;
+ } else if (item.type.substr(-11) == '-collection' && TYPE_DEFINITION[item.type.substr(0, item.type.length-11)]) {
+ iconContainer.style.boxShadow = '3px 3px var(--bs-btn-border-color)';
+ icon.className = TYPE_DEFINITION[item.type.substr(0, item.type.length-11)].dragIcon || 'fa-solid fa-question';
+ label.textContent = 'x' + item.values.length;
+ } else {
+ icon.className = 'fa-solid fa-question';
+ label.textContent = item.id || '';
+ }
+
+ iconContainer.append(icon);
+
+ const itemContainer = document.createElement('div');
+ itemContainer.className = 'd-flex flex-column align-items-center gap-2 small';
+ itemContainer.append(iconContainer, label);
+ return itemContainer;
+ });
+
+ const container = document.createElement('div');
+ container.className = 'd-flex flex-row gap-2 small';
+ container.append(...dragElements);
+
+ document.body.append(container);
+ event.dataTransfer.setDragImage(container, -25, 0);
+ requestAnimationFrame(() => {
+ document.body.removeChild(container);
+ });
+ }
+ if (Array.isArray(validDragObject)) {
+ return validDragObject.forEach(data => setTransferData(event, data));
+ }
+
+ event.dataTransfer.setData('fhc/' + validDragObject.type, JSON.stringify(validDragObject));
+}
+
+/**
+ * check if the dataTransfer types are in the allowed types array
+ * if strict is disabled at least one type must be the allowed array
+ * otherwise all types have to be in the allowed array
+ *
+ * @param Event event
+ * @param Array allowedTypes
+ * @param Boolean strict
+ */
+function eventHasTypes(event, allowedTypes, strict) {
+ if (!allowedTypes || !allowedTypes.length)
+ allowedTypes = VALID_TYPES;
+ allowedTypes = allowedTypes.map(type => 'fhc/' + type);
+
+ if (!strict)
+ return allowedTypes.some(type => event.dataTransfer.types.includes(type));
+
+ return event.dataTransfer.types.every(type => allowedTypes.includes(type));
+}
+
+export {
+ isValidDragObject,
+ getValidTransferData,
+ isValidTransferData,
+ getTransferData,
+ convertToValidDragObject,
+ setTransferData,
+ eventHasTypes,
+ dragendWorker
+};
+export default {
+ ...TYPE_CONSTANTS,
+ isValidDragObject,
+ getValidTransferData,
+ isValidTransferData,
+ getTransferData,
+ convertToValidDragObject,
+ setTransferData,
+ eventHasTypes,
+ dragendWorker
+};
diff --git a/public/js/sharedworkers/dragend.js b/public/js/sharedworkers/dragend.js
new file mode 100644
index 000000000..64179afa6
--- /dev/null
+++ b/public/js/sharedworkers/dragend.js
@@ -0,0 +1,30 @@
+let dragEndCallback = null;
+
+onconnect = e => {
+ const port = e.ports[0];
+
+ const cbList = [];
+
+ port.onmessage = e => {
+ const [ func, ...args ] = e.data;
+ switch (func) {
+ case 'init':
+ dragEndCallback = () => {
+ port.postMessage(['fire', args]);
+ };
+ break;
+ case 'block':
+ cbList[args[0]] = dragEndCallback;
+ dragEndCallback = null;
+ break;
+ case 'unblock':
+ cbList[args[0]]();
+ break;
+ case 'request':
+ if (dragEndCallback) {
+ dragEndCallback();
+ dragEndCallback = null;
+ }
+ }
+ };
+};