diff --git a/extension.json b/extension.json index 9593480..37ebada 100644 --- a/extension.json +++ b/extension.json @@ -72,7 +72,9 @@ "glmv-prev.css" ], "packageFiles": [ - "glmv-prev.js" + "glmv-prev.js", + "glmv-mvconfig.js", + "glmv-hs.js" ] } } diff --git a/modules/glmv-hs.js b/modules/glmv-hs.js new file mode 100644 index 0000000..b0e9a18 --- /dev/null +++ b/modules/glmv-hs.js @@ -0,0 +1,150 @@ +let deleteHotspot = null +let grabHotspot = null + +/** + * Sets listener and attributes on model-viewer to + * allow for click registering of a new hotspot + */ +readyAddHotspot = function() { + disableViewer('AddingHotspot', clickAddHotspot) +} + +/** + * Event listener callback to retrieve the info + * about the model surface point selected by the + * mouse and add that information to the editor + * text input + * + * @param {PointerEvent} e + */ +clickAddHotspot = function(e) { + let hsPosition = null + let targetModel = enableViewer() + if (targetModel) { + hsPosition = targetModel.positionAndNormalFromPoint(e.clientX, e.clientY) + } + if (hsPosition) { + let currentText = $('#wpTextbox1').val() + let [_, mvconfig] = extractMvconfig(currentText) + let hsOutput = {} + hsOutput['data-position'] = hsPosition.position.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m") + hsOutput['data-normal'] = hsPosition.normal.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m") + hsOutput['data-orbit'] = orb2degree(targetModel.getCameraOrbit().toString(),[2,2,5]) + let targetObj = targetModel.getCameraTarget() + hsOutput['data-target'] = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m` + mvconfig.annotations['Hotspot ' + (Object.keys(mvconfig.annotations).length + 1)] = hsOutput + let newText = currentText.replace(/(.*?)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${JSON.stringify(mvconfig, null, 2)}\n$2`) + $('#wpTextbox1').val(newText) + } + readMvconfig() +} + +/** + * Set flag and attributes on model-viewer to + * delete the next hotspot that is clicked + */ +readyDelHotspot = function() { + deleteHotspot = true + disableViewer('DeletingHotspot', cancelDeleteHotspot) +} + +/** + * Unset deleting flag and return normal + * function and style to model viewer + */ +cancelDeleteHotspot = function() { + deleteHotspot = null + enableViewer() +} + +/** + * Delete the selected hotspot + * + * @param {element} hs hotspot element to delete + */ +clickDeleteHotspot = function (hs) { + deleteHotspot = null + enableViewer() + const anName = hs.target.childNodes[0].innerText + let purgeAnnotation = new RegExp('(?<="annotationSets"[\\S\\s]*?)(^.*?' + anName + '.*\n)','gm') + hs.target.remove() + const editText = $('#wpTextbox1').val() + const newText = editText.replace(purgeAnnotation,'') + const finalText = newText.replace(/(,)(\n\s+])/gm,'$2') + $('#wpTextbox1').val(finalText) + writeMvconfig() + readMvconfig() +} + +/** + * Check status of delete function + */ +isDeleting = function() { + return deleteHotspot +} + +/** + * Prepare to drag a hotspot + * + * @param {MouseEvent} event + */ +grabAnnotation = function(e) { + if (e.ctrlKey) { + grabHotspot = {x: e.x, y: e.y} + const contEl = $('.glmv-container')[0] + contEl.addEventListener('mousemove', moveAnnotation) + const mvEl = $('model-viewer')[0] + } else { + grabHotspot = null + } +} + +/** + * Drag currently clicked hotspot + * + * @param {MouseEvent} event + */ +moveAnnotation = function(e) { + if (grabHotspot) { + grabHotspot.move = true + e.target.style['transform'] = `translate(${e.x - grabHotspot.x}px, ${e.y - grabHotspot.y}px) scale(1.1,1.1)` + } +} + +/** + * End dragging a hotspot and update information + * + * @param {MouseEvent} event + */ +releaseAnnotation = function(e) { + if (grabHotspot && grabHotspot.move) { + e.target.style['transform']='' + const contEl = $('.glmv-container')[0] + contEl.removeEventListener('mousemove', moveAnnotation) + const mvEl = $('model-viewer')[0] + let newPosition = mvEl.positionAndNormalFromPoint(e.clientX, e.clientY) + const newPos = newPosition.position.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m") + const newNorm = newPosition.normal.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m") + mvEl.updateHotspot({ + name: e.target.slot, + position: newPos, + normal: newNorm + }) + const newOrb = orb2degree(mvEl.getCameraOrbit().toString(),[2,2,5]) + e.target.setAttribute('data-orbit', newOrb) + let targetObj = mvEl.getCameraTarget() + const newTarg = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m` + e.target.setAttribute('data-target', newTarg) + let currentText = $('#wpTextbox1').val() + let [_, mvconfig] = extractMvconfig(currentText) + mvconfig.annotations[e.target.childNodes[0].innerText] = { + "data-position": newPos, + "data-normal": newNorm, + "data-orbit": newOrb, + "data-target": newTarg + } + const newText = currentText.replace(/(.*?)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${JSON.stringify(mvconfig, null, 2)}\n$2`) + $('#wpTextbox1').val(newText) + } + grabHotspot = null +} \ No newline at end of file diff --git a/modules/glmv-mvconfig.js b/modules/glmv-mvconfig.js new file mode 100644 index 0000000..5765104 --- /dev/null +++ b/modules/glmv-mvconfig.js @@ -0,0 +1,269 @@ +let currentSet = 'default' + +/** + * Convert text in the preview text editor to js object + * + * @return [string, object] full mvconfig text string and mvconfig object + */ +extractMvconfig = function() { + const editText = $('#wpTextbox1').val() + const extractConfig = editText.match(/([\S\s]*?)<\/mvconfig>/) + let mvconfig = (extractConfig.length >= 2) ? JSON.parse(extractConfig[1]) : {viewerConfig: {}, annotations: {}, annotationSets: {}} + if (mvconfig.viewerConfig === undefined) { + mvconfig.viewerConfig = { + default: { + "camera-controls": true + } + } + } + if (mvconfig.annotations === undefined) { + mvconfig.annotations = {} + } + if (mvconfig.annotationSets === undefined) { + mvconfig.annotationSets = {} + } + return [editText, mvconfig] +} + +/** + * Reads the json string in the edit panel + * and updates hotspot elements + * + * @return {bool} true on successful read and update + */ +readMvconfig = function() { + let hotspotsObj = [] + let mvconfig + let slotNum = 1 + + createHotspot = function(hsLabel, hsSlot, hsTag) { + let newHs = document.createElement('button') + newHs.classList.add('Hotspot') + newHs.setAttribute('slot',`hotspot-${hsSlot}`) + newHs.setAttribute('ontouchstart', 'event.stopPropagation()') + newHs.setAttribute('onclick', 'onAnnotation(event)') + newHs.setAttribute('onmousedown', 'grabAnnotation(event)') + newHs.setAttribute('onmouseup', 'releaseAnnotation(event)') + Object.keys(mvconfig.annotations[hsLabel]).forEach((prop) => { + newHs.setAttribute(prop, mvconfig.annotations[hsLabel][prop]) + }) + let newAn = document.createElement('div') + newAn.classList.add('HotspotAnnotation', 'HiddenAnnotation') + newAn.innerText = hsLabel + newHs.appendChild(newAn) + newLabel = document.createElement('span') + newLabel.innerText = hsTag || (hsSlot) + newHs.appendChild(newLabel) + hotspotsObj.push(newHs) + } + + try { + [_, mvconfig] = extractMvconfig() + } catch (err) { + console.warn('Failed to read model config:' + err.message) + return false + } + if (currentSet != 'default' && mvconfig.annotationSets[currentSet]) { + mvconfig.annotationSets[currentSet].forEach(hs => { + createHotspot(hs, slotNum) + slotNum += 1 + }) + } + Object.keys(mvconfig.annotations).forEach(hs => { + if (currentSet != 'default' && mvconfig.annotationSets[currentSet] && mvconfig.annotationSets[currentSet].includes(hs)) { + return + } + let label = (currentSet != 'default' && mvconfig.annotationSets[currentSet]) ? '-' : null + createHotspot(hs, slotNum, label) + slotNum += 1 + }) + + $('model-viewer button').remove() + const mView = $('model-viewer')[0] + hotspotsObj.forEach(hs => { + mView.appendChild(hs) + }) + + return true +} + +/** + * Parses the current hotspots into json object + * and writes the json string to the edit panel + * + * @return {bool} true on successful write to edit panel + */ +writeMvconfig = function () { + let annotationsObj = {} + currentButtons = $('.Hotspot').each(function() { + let buttonEl = $(this)[0] + annotationsObj[buttonEl.childNodes[0].innerText] = { + "data-position": buttonEl.getAttribute('data-position'), + "data-normal": buttonEl.getAttribute('data-normal'), + "data-orbit": buttonEl.getAttribute('data-orbit'), + "data-target": buttonEl.getAttribute('data-target') + } + }) + if (Object.keys(annotationsObj).length === 0) return false + const [currentText, mvconfig] = extractMvconfig() + mvconfig.annotations = annotationsObj + const newText = currentText.replace(/(.*?)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${JSON.stringify(mvconfig, null, 2)}\n$2`) + $('#wpTextbox1').val(newText) + return true +} + +/** + * Convert all radian values returned by model-viewer to degrees + * + * @param {string} orbString string with any number of `(number)rad` sub strings + * @param {number|array} fix Optional: number of decimal places to return in converted numbers + * @return {string} string with all radian values and units converted to degrees + */ +orb2degree = function(orbString, fix = null) { + let degArray = orbString.split(' ').map(s => { + if (s.includes('rad')) { + return (Number.parseFloat(s) / Math.PI * 180) + 'deg' + } else { + return s + } + }) + if (fix && !['number', 'object'].includes(typeof fix)) { + console.warn('orb2degree: fix parameter invalid type. Ignoring.') + fix = null + } + if (fix) { + degArray = degArray.map((v, idx) => { + let fixReg = new RegExp('(\\d*.\\d{' + (((typeof fix) == 'object') ? fix[idx] : fix) + '})(\\d*)([a-z]*)') + return v.replace(fixReg,'$1$3') + }) + } + return degArray.join(' ') +} + +/** + * Set camera control setting for the current view + * + * @param {string} view + * @return {bool} new camera-controls setting + */ +toggleCameraControl = function(view) { + let [currentText, mvconfig] = extractMvconfig() + const currentView = (mvconfig.viewerConfig[view]) ? view : 'default' + const newControl = !mvconfig.viewerConfig[currentView]['camera-controls'] + if (newControl) { + mvconfig.viewerConfig[currentView]['camera-controls'] = newControl + } else { + delete mvconfig.viewerConfig[currentView]['camera-controls'] + } + const textUpdate = currentText.replace(/(?<=)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${JSON.stringify(mvconfig, null, 2)}\n`) + $('#wpTextbox1').val(textUpdate) + return newControl +} + +selectViewConfig = function(view) { + const mView = $('model-viewer')[0] + let [_, mvconfig] = extractMvconfig() + const selectView = (mvconfig.viewerConfig[view]) ? view : 'default' + const viewConfig = mvconfig.viewerConfig[selectView] + const settings = [ + "camera-controls", + "disable-pan", + "disable-tap", + "touch-action", + "disable-zoom", + "orbit-sensitivity", + "zoom-sensitivity", + "pan-sensitivity", + "auto-rotate", + "auto-rotate-delay", + "rotation-per-second", + "interaction-prompt-style", + "interaction-prompt-threshold", + "camera-orbit", + "camera-target", + "field-of-view", + "max-camera-orbit", + "min-camera-orbit", + "max-field-of-view", + "min-field-of-view", + "poster", + "ar", + "ar-modes", + "ar-scale", + "ar-placement" + ] + settings.forEach(s => { + if (viewConfig[s]) { + mView.setAttribute(s,viewConfig[s]) + } else { + mView.removeAttribute(s) + } + }) +} + +/** + * Set new default camera orbit and send values to the preview + * editor + */ +writeCameraOrbit = function() { + const mView = $('model-viewer')[0] + const newOrbit = orb2degree(mView.getCameraOrbit().toString(),[2,2,5]) + mView.setAttribute('camera-orbit', newOrbit) + const targetObj = mView.getCameraTarget() + const newTarget = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m` + mView.setAttribute('camera-target', newTarget) + const newField = mView.getFieldOfView().toFixed(5) + 'deg' + mView.setAttribute('field-of-view',newField) + let [currentText, mvconfig] = extractMvconfig() + mvconfig.viewerConfig.default['camera-orbit'] = newOrbit + mvconfig.viewerConfig.default['camera-target'] = newTarget + mvconfig.viewerConfig.default['field-of-view'] = newField + const textUpdate = currentText.replace(/(?<=)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${JSON.stringify(mvconfig, null, 2)}\n`) + $('#wpTextbox1').val(textUpdate) +} + +/** + * Set new camera orbit limits and send values to the preview + * editor + * + * @param {string} axis [yaw|pitch] orbit value to set + * @param {string} limit [max|min] limit value to set + */ +writeCameraLimit = function(axis, limit) { + const mView = $('model-viewer')[0] + const newOrbit = orb2degree(mView.getCameraOrbit().toString(),[2,2,5]) + const newOrbitVals = newOrbit.split(' ') + const valueIndex = (axis == 'yaw') ? 0 : 1 + let [currentText, mvconfig] = extractMvconfig() + const oldOrbit = mvconfig.viewerConfig.default[`${limit}-camera-orbit`] + let oldOrbitVals = (oldOrbit) ? oldOrbit.split(' ') : Array(3).fill('auto') + oldOrbitVals[valueIndex] = newOrbitVals[valueIndex] + mvconfig.viewerConfig.default[`${limit}-camera-orbit`] = oldOrbitVals.join(' ') + mView.setAttribute(`${limit}-camera-orbit`, oldOrbitVals.join(' ')) + const textUpdate = currentText.replace(/(?<=)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${JSON.stringify(mvconfig, null, 2)}\n`) + $('#wpTextbox1').val(textUpdate) +} + +/** + * Change the currently selected annotation set + * + * @param {string} newSet name of annotation set to select + */ +selectAnnotationSet = function(newSet) { + currentSet = newSet + readMvconfig() +} + +/* +export default { + extractMvconfig, + readMvconfig, + writeMvconfig, + orb2degree, + toggleCameraControl, + selectViewConfig, + writeCameraOrbit, + writeCameraLimit, + selectAnnotationSet +} +*/ \ No newline at end of file diff --git a/modules/glmv-prev.js b/modules/glmv-prev.js index ddbb744..d6af30c 100644 --- a/modules/glmv-prev.js +++ b/modules/glmv-prev.js @@ -1,195 +1,282 @@ -let [_, origMetadata] = extractMetadata() +//Initialize globals +require('./glmv-mvconfig.js') +require('./glmv-hs.js') -//Annotation Edit Controls -const addHS = new OO.ui.ButtonWidget({ - icon: 'mapPinAdd', - label: 'Add annotation', - invisibleLabel: true, - class: 'edit-button' -}) -addHS.on('click', readyAddHotspot) -addHS.setDisabled(true) +/** + * Use the OOui js library to create wikis-style menu + * options on the preview edits page for interaction + * with the model + */ +buildPreviewMenu = function() { + let [_, origMetadata] = extractMvconfig() -const updateHS = new OO.ui.ButtonWidget({ - icon: 'reload', - label: 'Update annotations', - invisibleLabel: true -}) -updateHS.on('click', readMetadata) -updateHS.setDisabled(true) + //Annotation Edit Controls + const addHS = new OO.ui.ButtonWidget({ + icon: 'mapPinAdd', + label: 'Add annotation', + invisibleLabel: true, + class: 'edit-button' + }) + addHS.on('click', readyAddHotspot) + addHS.setDisabled(true) -const deleteHS = new OO.ui.ButtonWidget({ - icon: 'cancel', - label: 'Delete annotation', - invisibleLabel: true -}) -deleteHS.on('click', readyDelHotspot) -deleteHS.setDisabled(true) + const updateHS = new OO.ui.ButtonWidget({ + icon: 'reload', + label: 'Update annotations', + invisibleLabel: true + }) + updateHS.on('click', readMvconfig) + updateHS.setDisabled(true) -const setOptions = ['default', ...Object.keys(origMetadata.annotationSets)] -let setOptionItems = [] -setOptions.forEach(opt => { - setOptionItems.push(new OO.ui.MenuOptionWidget({data: opt, label: opt})) -}) + const deleteHS = new OO.ui.ButtonWidget({ + icon: 'cancel', + label: 'Delete annotation', + invisibleLabel: true + }) + deleteHS.on('click', readyDelHotspot) + deleteHS.setDisabled(true) -const setSelectHS = new OO.ui.ButtonMenuSelectWidget({ - icon: 'mapTrail', - label: 'Select annotation set', - invisibleLabel: true, - menu: { - items: setOptionItems, - width: 'min-content' - }, - $overlay: $('#bodyContent') -}) -setSelectHS.getMenu().on( 'choose', selSet => { - selectAnnotationSet(selSet.data) -}) -setSelectHS.setDisabled(true) + const setOptions = ['default', ...Object.keys(origMetadata.annotationSets)] + let setOptionItems = [] + setOptions.forEach(opt => { + setOptionItems.push(new OO.ui.MenuOptionWidget({data: opt, label: opt})) + }) -const hotspotButtons = new OO.ui.ButtonGroupWidget({ - items: [ addHS, updateHS, deleteHS, setSelectHS ] -}) + const setSelectHS = new OO.ui.ButtonMenuSelectWidget({ + icon: 'mapTrail', + label: 'Select annotation set', + invisibleLabel: true, + menu: { + items: setOptionItems, + width: 'min-content' + }, + $overlay: $('#bodyContent') + }) + setSelectHS.getMenu().on( 'choose', selSet => { + selectAnnotationSet(selSet.data) + }) + setSelectHS.setDisabled(true) -//View Edit Controls -const downloadViewerImage = new OO.ui.ButtonWidget({ - icon: 'imageAdd', - label: 'Download current image', - invisibleLabel: true -}) -downloadViewerImage.on('click', () => { - downloadImage(mw.config.values.wgTitle) -}) -downloadViewerImage.setDisabled(true) + const hotspotButtons = new OO.ui.ButtonGroupWidget({ + items: [ addHS, updateHS, deleteHS, setSelectHS ] + }) -const setView = new OO.ui.ButtonWidget({ - icon: 'camera', - label: 'Set Initial View', - invisibleLabel: true -}) -setView.on('click', writeCameraOrbit) -setView.setDisabled(true) + //View Edit Controls + const downloadViewerImage = new OO.ui.ButtonWidget({ + icon: 'imageAdd', + label: 'Download current image', + invisibleLabel: true + }) + downloadViewerImage.on('click', () => { + downloadImage(mw.config.values.wgTitle) + }) + downloadViewerImage.setDisabled(true) -const setControl = new OO.ui.ButtonWidget({ - icon: 'hand', - label: 'Toggle camera control', - invisibleLabel: true -}) -setControl.on('click', () => $('model-viewer')[0].toggleAttribute('camera-controls', toggleCameraControl())) -setControl.setDisabled(true) + const setView = new OO.ui.ButtonWidget({ + icon: 'camera', + label: 'Set Initial View', + invisibleLabel: true + }) + setView.on('click', writeCameraOrbit) + setView.setDisabled(true) -//View Limit Controls -const setMinYaw = new OO.ui.ButtonWidget({ - label: 'Min' -}) -setMinYaw.on('click', () => { - writeCameraLimit('yaw','min') -}) + const setControl = new OO.ui.ButtonWidget({ + icon: 'hand', + label: 'Toggle camera control', + invisibleLabel: true + }) + setControl.on('click', () => $('model-viewer')[0].toggleAttribute('camera-controls', toggleCameraControl())) + setControl.setDisabled(true) -const setMaxYaw = new OO.ui.ButtonWidget({ - label: 'Max' -}) -setMaxYaw.on('click', () => { - writeCameraLimit('yaw','max') -}) + //View Limit Controls + const setMinYaw = new OO.ui.ButtonWidget({ + label: 'Min' + }) + setMinYaw.on('click', () => { + writeCameraLimit('yaw','min') + }) -const yawLimitButtons = new OO.ui.ButtonGroupWidget({ - items: [ setMinYaw, setMaxYaw ] -}) + const setMaxYaw = new OO.ui.ButtonWidget({ + label: 'Max' + }) + setMaxYaw.on('click', () => { + writeCameraLimit('yaw','max') + }) -const labelYaw = new OO.ui.LabelWidget({ - label: "Yaw:" -}) + const yawLimitButtons = new OO.ui.ButtonGroupWidget({ + items: [ setMinYaw, setMaxYaw ] + }) -const yawButtons = new OO.ui.HorizontalLayout({ - items: [ - labelYaw, - yawLimitButtons - ], - id: 'yaw-limits' -}) + const labelYaw = new OO.ui.LabelWidget({ + label: "Yaw:" + }) -const setMinPitch = new OO.ui.ButtonWidget({ - label: 'Min' -}) -setMinPitch.on('click', () => { - writeCameraLimit('pitch','min') -}) + const yawButtons = new OO.ui.HorizontalLayout({ + items: [ + labelYaw, + yawLimitButtons + ], + id: 'yaw-limits' + }) -const setMaxPitch = new OO.ui.ButtonWidget({ - label: 'Max' -}) -setMaxPitch.on('click', () => { - writeCameraLimit('pitch','max') -}) + const setMinPitch = new OO.ui.ButtonWidget({ + label: 'Min' + }) + setMinPitch.on('click', () => { + writeCameraLimit('pitch','min') + }) -const pitchLimitButtons = new OO.ui.ButtonGroupWidget({ - items: [ setMinPitch, setMaxPitch ] -}) + const setMaxPitch = new OO.ui.ButtonWidget({ + label: 'Max' + }) + setMaxPitch.on('click', () => { + writeCameraLimit('pitch','max') + }) -const labelPitch = new OO.ui.LabelWidget({ - label: "Pitch:" -}) + const pitchLimitButtons = new OO.ui.ButtonGroupWidget({ + items: [ setMinPitch, setMaxPitch ] + }) -const pitchButtons = new OO.ui.HorizontalLayout({ - items: [ - labelPitch, - pitchLimitButtons - ], - id: 'pitch-limits' -}) + const labelPitch = new OO.ui.LabelWidget({ + label: "Pitch:" + }) -const setLims = new OO.ui.PopupButtonWidget({ - label: 'Set View Limits', - invisibleLabel: true, - icon: 'tableMergeCells', - popup: { - $content: yawButtons.$element.add(pitchButtons.$element), - padded: true, - position: 'above' - }, - $overlay: $('#bodyContent') -}) -setLims.setDisabled(true) + const pitchButtons = new OO.ui.HorizontalLayout({ + items: [ + labelPitch, + pitchLimitButtons + ], + id: 'pitch-limits' + }) -const setViewConfig = [...Object.keys(origMetadata.viewerConfig)] -let setViewItems = [] -setViewConfig.forEach(opt => { - setViewItems.push(new OO.ui.MenuOptionWidget({data: opt, label: opt})) -}) + const setLims = new OO.ui.PopupButtonWidget({ + label: 'Set View Limits', + invisibleLabel: true, + icon: 'tableMergeCells', + popup: { + $content: yawButtons.$element.add(pitchButtons.$element), + padded: true, + position: 'above' + }, + $overlay: $('#bodyContent') + }) + setLims.setDisabled(true) -const selectVC = new OO.ui.ButtonMenuSelectWidget({ - icon: 'eye', - label: 'Select view configuration', - invisibleLabel: true, - menu: { - items: setViewItems, - width: 'min-content' - }, - $overlay: $('#bodyContent') -}) -selectVC.getMenu().on( 'choose', selSet => { - selectViewConfig(selSet.data) -}) -selectVC.setDisabled(true) + const setViewConfig = [...Object.keys(origMetadata.viewerConfig)] + let setViewItems = [] + setViewConfig.forEach(opt => { + setViewItems.push(new OO.ui.MenuOptionWidget({data: opt, label: opt})) + }) -const cameraButtons = new OO.ui.ButtonGroupWidget({ - items: [ downloadViewerImage, setControl, setView, setLims, selectVC ] -}) + const selectVC = new OO.ui.ButtonMenuSelectWidget({ + icon: 'eye', + label: 'Select view configuration', + invisibleLabel: true, + menu: { + items: setViewItems, + width: 'min-content' + }, + $overlay: $('#bodyContent') + }) + selectVC.getMenu().on( 'choose', selSet => { + selectViewConfig(selSet.data) + }) + selectVC.setDisabled(true) -//Main Menu -const modelMenu = new OO.ui.HorizontalLayout({ - items: [ - hotspotButtons, - cameraButtons - ], - id: 'edit-model-menu' -}) + const cameraButtons = new OO.ui.ButtonGroupWidget({ + items: [ downloadViewerImage, setControl, setView, setLims, selectVC ] + }) -$('#wikiPreview').after(modelMenu.$element) + //Main Menu + const modelMenu = new OO.ui.HorizontalLayout({ + items: [ + hotspotButtons, + cameraButtons + ], + id: 'edit-model-menu' + }) + $('#wikiPreview').after(modelMenu.$element) + + return [modelMenu, selectVC] +} + +/** + * Enable all the preview menu widgets (called by + * model load event) + */ enableMenu = function() { modelMenu.items.forEach(group => { group.items.forEach(el => el.setDisabled(false)) }); -} \ No newline at end of file +} + +/** + * Add a selection option to a ButtonMenuSelectWidget + * + * @param {ButtonMenuSelectWidget} menuWidget + */ +addMenuOption = function(menuWidget) { + menuWidget.menu.addItems([new OO.ui.MenuOptionWidget({data: 'New option', label: 'New option'})]) +} + +/** + * Disable general interaction with model + * viewer for specific additional function + * + * @param {string} fnClass class to add to model-viewer + * @param {callback} viewCall callback function to add to model-viewer + * @return {Element} model-viewer element + */ +disableViewer = function(fnClass, viewCall) { + const previewMv = $('model-viewer') + if (viewCall) previewMv.one('click', viewCall) + if (fnClass) previewMv.addClass(fnClass) + previewMv[0].disableTap = true + previewMv[0].toggleAttribute('camera-controls', false) + return previewMv[0] +} + +/** + * Enable general interaction with model + * viewer + * + * @return {Element} model-viewer element + */ +enableViewer = function() { + const previewMv = $('model-viewer') + previewMv.off('click', clickAddHotspot) + previewMv.off('click', cancelDeleteHotspot) + previewMv.removeClass('AddingHotspot DeletingHotspot') + previewMv[0].disableTap = false + previewMv[0].toggleAttribute('camera-controls', true) + return previewMv[0] +} + +/** + * Use the model viewer methods to get image + * of current view and download + * + * @param {string} defName wiki page name to use as base file name + */ +downloadImage = function(defName) { + const imgName = defName.split('.')[0] + const mView = $('model-viewer')[0] + + const dlA = document.createElement('a') + dlA.setAttribute('download',imgName + '.png') + + const reader = new FileReader() + reader.addEventListener("load", () => { + dlA.setAttribute('href',reader.result) + dlA.click() + },{once: true}) + + mView.toBlob(null, null, true) + .then(imgBlob => { + reader.readAsDataURL(imgBlob) + }) +} + +//Initialize the menu and get required global objects +const [modelMenu, viewSelector] = buildPreviewMenu() \ No newline at end of file diff --git a/modules/glmv.js b/modules/glmv.js index 8647f8e..98ae1ca 100644 --- a/modules/glmv.js +++ b/modules/glmv.js @@ -1,7 +1,4 @@ let slideShowInterval = null -let grabHotspot = null -let deleteHotspot = null -let currentSet = 'default' /** * Disables hiding of various child items @@ -12,174 +9,6 @@ modelLoaded = function(e) { if (typeof enableMenu != 'undefined') {enableMenu()} } -/** - * Reads the json string in the edit panel - * and updates hotspot elements - * - * @return {bool} true on successful read and update - */ -readMetadata = function() { - let hotspotsObj = [] - let metadata - let slotNum = 1 - - createHotspot = function(hsLabel, hsSlot, hsTag) { - let newHs = document.createElement('button') - newHs.classList.add('Hotspot') - newHs.setAttribute('slot',`hotspot-${hsSlot}`) - newHs.setAttribute('ontouchstart', 'event.stopPropagation()') - newHs.setAttribute('onclick', 'onAnnotation(event)') - newHs.setAttribute('onmousedown', 'grabAnnotation(event)') - newHs.setAttribute('onmouseup', 'releaseAnnotation(event)') - Object.keys(metadata.annotations[hsLabel]).forEach((prop) => { - newHs.setAttribute(prop, metadata.annotations[hsLabel][prop]) - }) - let newAn = document.createElement('div') - newAn.classList.add('HotspotAnnotation', 'HiddenAnnotation') - newAn.innerText = hsLabel - newHs.appendChild(newAn) - newLabel = document.createElement('span') - newLabel.innerText = hsTag || (hsSlot) - newHs.appendChild(newLabel) - hotspotsObj.push(newHs) - } - - try { - [_, metadata] = extractMetadata() - } catch (err) { - console.warn('Failed to read model metadata:' + err.message) - return false - } - if (currentSet != 'default' && metadata.annotationSets[currentSet]) { - metadata.annotationSets[currentSet].forEach(hs => { - createHotspot(hs, slotNum) - slotNum += 1 - }) - } - Object.keys(metadata.annotations).forEach(hs => { - if (currentSet != 'default' && metadata.annotationSets[currentSet] && metadata.annotationSets[currentSet].includes(hs)) { - return - } - let label = (currentSet != 'default' && metadata.annotationSets[currentSet]) ? '-' : null - createHotspot(hs, slotNum, label) - slotNum += 1 - }) - - $('model-viewer button').remove() - const mView = $('model-viewer')[0] - hotspotsObj.forEach(hs => { - mView.appendChild(hs) - }) - - return true -} - -/** - * Parses the current hotspots into json object - * and writes the json string to the edit panel - * - * @return {bool} true on successful write to edit panel - */ -writeMetadata = function () { - let annotationsObj = {} - currentButtons = $('.Hotspot').each(function() { - let buttonEl = $(this)[0] - annotationsObj[buttonEl.childNodes[0].innerText] = { - "data-position": buttonEl.getAttribute('data-position'), - "data-normal": buttonEl.getAttribute('data-normal'), - "data-orbit": buttonEl.getAttribute('data-orbit'), - "data-target": buttonEl.getAttribute('data-target') - } - }) - if (Object.keys(annotationsObj).length === 0) return false - const [currentText, metadata] = extractMetadata() - metadata.annotations = annotationsObj - const newText = currentText.replace(/(.*?)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${JSON.stringify(metadata, null, 2)}\n$2`) - $('#wpTextbox1').val(newText) - return true -} - -/** - * Convert text in the preview text editor to js object - * - * @return object containing metadata information - */ -extractMetadata = function() { - const editText = $('#wpTextbox1').val() - const extractMetadata = editText.match(/([\S\s]*?)<\/mvconfig>/) - let metadata = (extractMetadata.length >= 2) ? JSON.parse(extractMetadata[1]) : {viewerConfig: {}, annotations: {}, annotationSets: {}} - if (metadata.viewerConfig === undefined) { - metadata.viewerConfig = { - default: { - "camera-controls": true - } - } - } - if (metadata.annotations === undefined) { - metadata.annotations = {} - } - if (metadata.annotationSets === undefined) { - metadata.annotationSets = {} - } - return [editText, metadata] -} - -/** - * Sets listener and attributes on model-viewer to - * allow for click registering of a new hotspot - */ -readyAddHotspot = function() { - disableViewer('AddingHotspot', clickAddHotspot) -} - -/** - * Event listener callback to retrieve the info - * about the model surface point selected by the - * mouse and add that information to the editor - * text input - * - * @param {PointerEvent} e - */ -clickAddHotspot = function(e) { - let hsPosition = null - let targetModel = enableViewer() - if (targetModel) { - hsPosition = targetModel.positionAndNormalFromPoint(e.clientX, e.clientY) - } - if (hsPosition) { - let currentText = $('#wpTextbox1').val() - let [_, metadata] = extractMetadata(currentText) - let hsOutput = {} - hsOutput['data-position'] = hsPosition.position.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m") - hsOutput['data-normal'] = hsPosition.normal.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m") - hsOutput['data-orbit'] = orb2degree(targetModel.getCameraOrbit().toString(),[2,2,5]) - let targetObj = targetModel.getCameraTarget() - hsOutput['data-target'] = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m` - metadata.annotations['Hotspot ' + (Object.keys(metadata.annotations).length + 1)] = hsOutput - let newText = currentText.replace(/(.*?)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${JSON.stringify(metadata, null, 2)}\n$2`) - $('#wpTextbox1').val(newText) - } - readMetadata() -} - -/** - * Set flag and attributes on model-viewer to - * delete the next hotspot that is clicked - */ -readyDelHotspot = function() { - deleteHotspot = true - disableViewer('DeletingHotspot', cancelDeleteHotspot) -} - -/** - * Unset deleting flag and return normal - * function and style to model viewer - */ -cancelDeleteHotspot = function() { - deleteHotspot = null - enableViewer() -} - /** * Event listener callback to toggle the visibility * of a hotspot's annotation when the hotspot is @@ -189,19 +18,9 @@ cancelDeleteHotspot = function() { */ onAnnotation = function(e) { e.stopPropagation() - if (deleteHotspot) { - deleteHotspot = null - enableViewer() - const anName = e.target.childNodes[0].innerText - let purgeAnnotation = new RegExp('(?<="annotationSets"[\\S\\s]*?)(^.*?' + anName + '.*\n)','gm') - e.target.remove() - const editText = $('#wpTextbox1').val() - const newText = editText.replace(purgeAnnotation,'') - const finalText = newText.replace(/(,)(\n\s+])/gm,'$2') - $('#wpTextbox1').val(finalText) - writeMetadata() - readMetadata() - return + if (typeof isDeleting != 'undefined' && isDeleting()) { + clickDeleteHotspot(e) + return } let targetAnnotation = Number.parseInt(e.target.slot.split('-')[1]) let targetModel = e.target.closest('model-viewer') @@ -326,130 +145,6 @@ toggleAnnotations = function(mView) { }) } -/** - * Prepare to drag a hotspot - * - * @param {MouseEvent} event - */ -grabAnnotation = function(e) { - if (e.ctrlKey) { - grabHotspot = {x: e.x, y: e.y} - const contEl = $('.glmv-container')[0] - contEl.addEventListener('mousemove', moveAnnotation) - const mvEl = $('model-viewer')[0] - } else { - grabHotspot = null - } -} - -/** - * Drag currently clicked hotspot - * - * @param {MouseEvent} event - */ -moveAnnotation = function(e) { - if (grabHotspot) { - grabHotspot.move = true - e.target.style['transform'] = `translate(${e.x - grabHotspot.x}px, ${e.y - grabHotspot.y}px) scale(1.1,1.1)` - } -} - -/** - * End dragging a hotspot and update information - * - * @param {MouseEvent} event - */ -releaseAnnotation = function(e) { - if (grabHotspot && grabHotspot.move) { - e.target.style['transform']='' - const contEl = $('.glmv-container')[0] - contEl.removeEventListener('mousemove', moveAnnotation) - const mvEl = $('model-viewer')[0] - let newPosition = mvEl.positionAndNormalFromPoint(e.clientX, e.clientY) - const newPos = newPosition.position.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m") - const newNorm = newPosition.normal.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m") - mvEl.updateHotspot({ - name: e.target.slot, - position: newPos, - normal: newNorm - }) - const newOrb = orb2degree(mvEl.getCameraOrbit().toString(),[2,2,5]) - e.target.setAttribute('data-orbit', newOrb) - let targetObj = mvEl.getCameraTarget() - const newTarg = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m` - e.target.setAttribute('data-target', newTarg) - let currentText = $('#wpTextbox1').val() - let [_, metadata] = extractMetadata(currentText) - metadata.annotations[e.target.childNodes[0].innerText] = { - "data-position": newPos, - "data-normal": newNorm, - "data-orbit": newOrb, - "data-target": newTarg - } - const newText = currentText.replace(/(.*?)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${JSON.stringify(metadata, null, 2)}\n$2`) - $('#wpTextbox1').val(newText) - } - grabHotspot = null -} - -/** - * Disable general interaction with model - * viewer for specific additional function - * - * @param {string} fnClass class to add to model-viewer - * @param {callback} viewCall callback function to add to model-viewer - * @return {Element} model-viewer element - */ -disableViewer = function(fnClass, viewCall) { - const previewMv = $('model-viewer') - if (viewCall) previewMv.one('click', viewCall) - if (fnClass) previewMv.addClass(fnClass) - previewMv[0].disableTap = true - previewMv[0].toggleAttribute('camera-controls', false) - return previewMv[0] -} - -/** - * Enable general interaction with model - * viewer - * - * @return {Element} model-viewer element - */ -enableViewer = function() { - const previewMv = $('model-viewer') - previewMv.off('click', clickAddHotspot) - previewMv.off('click', cancelDeleteHotspot) - previewMv.removeClass('AddingHotspot DeletingHotspot') - previewMv[0].disableTap = false - previewMv[0].toggleAttribute('camera-controls', true) - return previewMv[0] -} - -/** - * Use the model viewer methods to get image - * of current view and download - * - * @param {string} defName wiki page name to use as base file name - */ -downloadImage = function(defName) { - const imgName = defName.split('.')[0] - const mView = $('model-viewer')[0] - - const dlA = document.createElement('a') - dlA.setAttribute('download',imgName + '.png') - - const reader = new FileReader() - reader.addEventListener("load", () => { - dlA.setAttribute('href',reader.result) - dlA.click() - },{once: true}) - - mView.toBlob(null, null, true) - .then(imgBlob => { - reader.readAsDataURL(imgBlob) - }) -} - /** * Respond to full screen changes on the gl container * @@ -461,146 +156,4 @@ toggleFullScreen = function(glCont) { } else { glCont.requestFullscreen() } -} - -/** - * Set camera control setting for the current view - * - * @param {string} view - * @return {bool} new camera-controls setting - */ -toggleCameraControl = function(view) { - let [currentText, mvconfig] = extractMetadata() - const currentView = (mvconfig.viewerConfig[view]) ? view : 'default' - const newControl = !mvconfig.viewerConfig[currentView]['camera-controls'] - if (newControl) { - mvconfig.viewerConfig[currentView]['camera-controls'] = newControl - } else { - delete mvconfig.viewerConfig[currentView]['camera-controls'] - } - const textUpdate = currentText.replace(/(?<=)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${JSON.stringify(mvconfig, null, 2)}\n`) - $('#wpTextbox1').val(textUpdate) - return newControl -} - -selectViewConfig = function(view) { - const mView = $('model-viewer')[0] - let [_, mvconfig] = extractMetadata() - const selectView = (mvconfig.viewerConfig[view]) ? view : 'default' - const viewConfig = mvconfig.viewerConfig[selectView] - const settings = [ - "camera-controls", - "disable-pan", - "disable-tap", - "touch-action", - "disable-zoom", - "orbit-sensitivity", - "zoom-sensitivity", - "pan-sensitivity", - "auto-rotate", - "auto-rotate-delay", - "rotation-per-second", - "interaction-prompt-style", - "interaction-prompt-threshold", - "camera-orbit", - "camera-target", - "field-of-view", - "max-camera-orbit", - "min-camera-orbit", - "max-field-of-view", - "min-field-of-view", - "poster", - "ar", - "ar-modes", - "ar-scale", - "ar-placement" - ] - settings.forEach(s => { - if (viewConfig[s]) { - mView.setAttribute(s,viewConfig[s]) - } else { - mView.removeAttribute(s) - } - }) -} - -/** - * Set new default camera orbit and send values to the preview - * editor - */ -writeCameraOrbit = function() { - const mView = $('model-viewer')[0] - const newOrbit = orb2degree(mView.getCameraOrbit().toString(),[2,2,5]) - mView.setAttribute('camera-orbit', newOrbit) - const targetObj = mView.getCameraTarget() - const newTarget = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m` - mView.setAttribute('camera-target', newTarget) - const newField = mView.getFieldOfView().toFixed(5) + 'deg' - mView.setAttribute('field-of-view',newField) - let [currentText, mvconfig] = extractMetadata() - mvconfig.viewerConfig.default['camera-orbit'] = newOrbit - mvconfig.viewerConfig.default['camera-target'] = newTarget - mvconfig.viewerConfig.default['field-of-view'] = newField - const textUpdate = currentText.replace(/(?<=)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${JSON.stringify(mvconfig, null, 2)}\n`) - $('#wpTextbox1').val(textUpdate) -} - -/** - * Set new camera orbit limits and send values to the preview - * editor - * - * @param {string} axis [yaw|pitch] orbit value to set - * @param {string} limit [max|min] limit value to set - */ -writeCameraLimit = function(axis, limit) { - const mView = $('model-viewer')[0] - const newOrbit = orb2degree(mView.getCameraOrbit().toString(),[2,2,5]) - const newOrbitVals = newOrbit.split(' ') - const valueIndex = (axis == 'yaw') ? 0 : 1 - let [currentText, mvconfig] = extractMetadata() - const oldOrbit = mvconfig.viewerConfig.default[`${limit}-camera-orbit`] - let oldOrbitVals = (oldOrbit) ? oldOrbit.split(' ') : Array(3).fill('auto') - oldOrbitVals[valueIndex] = newOrbitVals[valueIndex] - mvconfig.viewerConfig.default[`${limit}-camera-orbit`] = oldOrbitVals.join(' ') - mView.setAttribute(`${limit}-camera-orbit`, oldOrbitVals.join(' ')) - const textUpdate = currentText.replace(/(?<=)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${JSON.stringify(mvconfig, null, 2)}\n`) - $('#wpTextbox1').val(textUpdate) -} - -/** - * Convert all radian values returned by model-viewer to degrees - * - * @param {string} orbString string with any number of `(number)rad` sub strings - * @param {number|array} fix Optional: number of decimal places to return in converted numbers - * @return {string} string with all radian values and units converted to degrees - */ -orb2degree = function(orbString, fix = null) { - let degArray = orbString.split(' ').map(s => { - if (s.includes('rad')) { - return (Number.parseFloat(s) / Math.PI * 180) + 'deg' - } else { - return s - } - }) - if (fix && !['number', 'object'].includes(typeof fix)) { - console.warn('orb2degree: fix parameter invalid type. Ignoring.') - fix = null - } - if (fix) { - degArray = degArray.map((v, idx) => { - let fixReg = new RegExp('(\\d*.\\d{' + (((typeof fix) == 'object') ? fix[idx] : fix) + '})(\\d*)([a-z]*)') - return v.replace(fixReg,'$1$3') - }) - } - return degArray.join(' ') -} - -/** - * Change the currently selected annotation set - * - * @param {string} newSet name of annotation set to select - */ -selectAnnotationSet = function(newSet) { - currentSet = newSet - readMetadata() } \ No newline at end of file