/** * 3D Foundation Project * Copyright 2025 Smithsonian Institution * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { customElement, html } from "@ff/ui/CustomElement"; import { IButtonClickEvent } from "@ff/ui/Button"; import "@ff/ui/LineEdit"; import { ILineEditChangeEvent } from "@ff/ui/LineEdit"; import "@ff/ui/TextEdit"; import "@ff/ui/Splitter"; import Notification from "@ff/ui/Notification"; import "./AnnotationList"; import { ISelectAnnotationEvent } from "./AnnotationList"; import CVAnnotationView from "../../components/CVAnnotationView"; import CVAnnotationsTask, { EAnnotationsTaskMode } from "../../components/CVAnnotationsTask"; import { TaskView } from "../../components/CVTask"; import { ELanguageStringType, ELanguageType } from "client/schema/common"; import sanitizeHtml from 'sanitize-html'; import CVMediaManager from "client/components/CVMediaManager"; //////////////////////////////////////////////////////////////////////////////// export const MAX_LEAD_CHARS = 200; @customElement("sv-annotations-task-view") export default class AnnotationsTaskView extends TaskView { private _dragCounter = 0; private _leadLimit = MAX_LEAD_CHARS; private _leadCharCount = 0; protected sceneview : HTMLElement = null; protected connected() { super.connected(); // get sceneview for cursor updates const explorer = (this.getRootNode() as Element).getElementsByTagName("voyager-explorer")[0]; this.sceneview = explorer.shadowRoot.querySelector(".sv-scene-view") as HTMLElement; this.task.on("update", this.onUpdate, this); this.activeDocument.setup.language.ins.activeLanguage.on("value", this.onUpdate, this); this.activeDocument.setup.language.ins.primarySceneLanguage.on("value", this.onUpdate, this); } protected disconnected() { this.activeDocument.setup.language.ins.activeLanguage.off("value", this.onUpdate, this); this.activeDocument.setup.language.ins.primarySceneLanguage.on("value", this.onUpdate, this); this.task.off("update", this.onUpdate, this); // set cursor to grab when leaving this.sceneview.style.cursor = "grab"; super.disconnected(); } protected render() { if(!this.activeDocument) { return; } const node = this.activeNode; const annotations = node && node.getComponent(CVAnnotationView, true); const languageManager = this.activeDocument.setup.language; const primarySceneLanguage = ELanguageType[languageManager.ins.primarySceneLanguage.value]; const activeLanguage = ELanguageType[languageManager.ins.activeLanguage.value]; if (!annotations) { // set cursor to grab this.sceneview.style.cursor = "grab"; return html`
${languageManager.getUILocalizedString("Please select a model to edit its annotations")}
`; } this.sceneview.style.cursor = this.task.ins.mode.value > 0 ? "default" : "grab"; const inProps = annotations.ins; const audioProp = this.task.ins.audio; const modeProp = this.task.ins.mode; const annotationList = annotations.getAnnotations(); const annotation = annotations.activeAnnotation; this._leadLimit = this._leadLimit == 0 ? 0 : (MAX_LEAD_CHARS - (inProps.image.value || inProps.audioId.value ? 50 : 0)); this._leadCharCount = inProps.lead.value.length; const limitText = this._leadLimit == 0 ? "infinite" : this._leadLimit; const overLimit = this._leadCharCount > this._leadLimit && this._leadLimit != 0; const imagePropView = inProps.image.value.length > 0 ? html`
` : null; //
Title
// //
Tags
// const detailView = annotation ? html`
${imagePropView}
this.onClickLimit(e)}>Lead   ${this._leadCharCount}/${limitText}
${languageManager.getUILocalizedString("View Point")}
` : null; return html`
${languageManager.getUILocalizedString("Default:") + " " + primarySceneLanguage}
${languageManager.getUILocalizedString("Active:") + " " + activeLanguage}
${detailView}
`; } protected onTextEdit(event: ILineEditChangeEvent) { const annotations = this.task.activeAnnotations; if (annotations) { const target = event.target; const text = event.detail.text; if (target.name === "lead") { annotations.ins.lead.setValue(sanitizeHtml(text, { allowedTags: [ 'b', 'i', 'em', 'strong', 'a', 'sup', 'sub' ], allowedAttributes: { 'a': [ 'href', 'target' ] } } )); annotations.activeAnnotation.leadChanged = true; } } } /** * User clicked a mode button. */ protected onClickMode(event: IButtonClickEvent) { this.task.ins.mode.setValue(event.target.index); } /** * User clicked the delete button. */ protected onClickDelete() { this.task.removeAnnotation(); } /** * User clicked the save view button. */ protected onSaveView() { this.task.saveAnnotationView(); this.requestUpdate(); } /** * User clicked the restore view button. */ protected onRestoreView() { this.task.restoreAnnotationView(); } /** * User clicked the delete view button. */ protected onDeleteView() { this.task.deleteAnnotationView(); this.requestUpdate(); } /** * SUPER SECRET LIMIT OVERRIDE (shhh!) */ protected onClickLimit(e: MouseEvent) { if(e.ctrlKey && e.shiftKey) { this._leadLimit = 0; (this.getElementsByTagName("ff-text-edit")[0] as HTMLElement).blur(); this.requestUpdate(); } } /** * User clicked an entry in the annotation list. */ protected onSelectAnnotation(event: ISelectAnnotationEvent) { if (this.task.activeAnnotations) { this.task.activeAnnotations.activeAnnotation = event.detail.annotation; this.task.ins.selection.set(); const ins = this.task.activeAnnotations.ins; this._leadCharCount = ins.lead.value.length; this._leadLimit = MAX_LEAD_CHARS - (ins.image.value ? 100 : 0) - (ins.image.value || ins.audioId.value ? 50 : 0); if(this._leadCharCount > MAX_LEAD_CHARS && !event.detail.annotation.leadChanged) { this._leadLimit = 0; } } } /** Handle image file dropping **TODO: Merge with audio drop handler*/ protected onDropFile(event: DragEvent) { event.preventDefault(); let filename = ""; let newFile : File = null; const imageProp = this.task.activeAnnotations.ins.image; const element = event.target as HTMLElement; if(element.tagName != "INPUT") { return; } if(event.dataTransfer.files.length === 1) { newFile = event.dataTransfer.files.item(0); filename = newFile.name; } else { const filepath = event.dataTransfer.getData("text/plain"); if(filepath.length > 0) { filename = filepath; } } if(filename.toLowerCase().endsWith(".jpg") || filename.toLowerCase().endsWith(".png")) { if(newFile !== null) { const mediaManager = this.system.getMainComponent(CVMediaManager); mediaManager.uploadFile(filename, newFile, mediaManager.root).then(() => imageProp.setValue(filename)).catch(e => { Notification.show(`Image file upload failed.`, "warning"); imageProp.setValue(""); }); } else { this.task.activeAnnotations.ins.image.setValue(filename); } } else { Notification.show(`Unable to load - Only .jpg and .png files are currently supported.`, "warning"); } element.classList.remove("sv-drop-zone"); this._dragCounter = 0; } protected onDragEnter(event: DragEvent) { const element = event.target as HTMLElement; if(element.tagName == "INPUT") { element.classList.add("sv-drop-zone"); event.preventDefault(); this._dragCounter++; } } protected onDragOver(event: DragEvent) { event.preventDefault(); } protected onDragLeave(event: DragEvent) { const element = event.target as HTMLElement; if(element.tagName == "INPUT") { this._dragCounter--; if(this._dragCounter === 0) { element.classList.remove("sv-drop-zone"); } } } }