diff --git a/src/ui/camera-info-overlay.ts b/src/ui/camera-info-overlay.ts index be875783..f5c3b1e5 100644 --- a/src/ui/camera-info-overlay.ts +++ b/src/ui/camera-info-overlay.ts @@ -1,9 +1,27 @@ import { Container, Label } from '@playcanvas/pcui'; +import { Vec3 } from 'playcanvas'; import { Events } from '../events'; import { i18n } from './localization'; import { Tooltips } from './tooltips'; +// Accepts "1,2,3", "1, 2, 3", "1 2 3", with or without trailing whitespace. +const parseVector = (text: string): [number, number, number] | null => { + const parts = text.trim().split(/[\s,]+/).filter(p => p.length > 0); + if (parts.length !== 3) return null; + const nums = parts.map(Number); + if (nums.some(n => !Number.isFinite(n))) return null; + return [nums[0], nums[1], nums[2]]; +}; + +const flash = (dom: HTMLElement, cls: string) => { + dom.classList.add(cls); + setTimeout(() => dom.classList.remove(cls), 250); +}; + +// round to 2 decimals and drop trailing zeros ("0.00" -> "0", "0.50" -> "0.5") +const fmt = (n: number) => `${parseFloat(n.toFixed(2))}`; + class CameraInfoOverlay extends Container { constructor(events: Events, tooltips: Tooltips) { super({ @@ -16,15 +34,15 @@ class CameraInfoOverlay extends Container { this.dom.addEventListener(eventName, (event: Event) => event.stopPropagation()); }); - const createRow = (localeKey: string) => { + const createRow = (letter: string, tooltipKey: string, apply: (value: [number, number, number]) => void) => { const row = new Container({ class: 'camera-info-row' }); const key = new Label({ - class: 'camera-info-key' + class: 'camera-info-key', + text: letter }); - i18n.bindText(key, localeKey); const value = new Label({ class: 'camera-info-value' @@ -34,40 +52,96 @@ class CameraInfoOverlay extends Container { row.append(value); this.append(row); - // display holds the latest formatted value, copyText the full - // precision equivalent. copiedUntil suppresses live updates while - // the 'Copied!' feedback is showing. - const state = { copyText: '', display: '', copiedUntil: 0 }; - - row.dom.addEventListener('pointerdown', () => { - navigator.clipboard.writeText(state.copyText); + tooltips.register(row, () => i18n.t(tooltipKey), 'top'); + + const dom = value.dom; + dom.setAttribute('contenteditable', 'plaintext-only'); + dom.setAttribute('spellcheck', 'false'); + + // display holds the latest formatted value; live updates are + // suppressed while the user is editing so their input isn't + // overwritten by the per-frame refresh. + let display = ''; + let editing = false; + let canceled = false; + + dom.addEventListener('focus', () => { + editing = true; + canceled = false; + // select all text so the user can start typing to replace + const range = document.createRange(); + range.selectNodeContents(dom); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + }); - state.copiedUntil = performance.now() + 1000; - value.text = i18n.t('cursor.copied'); - setTimeout(() => { - if (performance.now() >= state.copiedUntil) { - value.text = state.display; - } - }, 1000); + // stop key events reaching the editor's global shortcut handlers + const stopKey = (e: KeyboardEvent) => e.stopPropagation(); + dom.addEventListener('keydown', (e: KeyboardEvent) => { + stopKey(e); + if (e.key === 'Enter') { + e.preventDefault(); + dom.blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + canceled = true; + dom.blur(); + } }); + dom.addEventListener('keyup', stopKey); + dom.addEventListener('keypress', stopKey); + + dom.addEventListener('blur', () => { + const wasCanceled = canceled; + editing = false; + canceled = false; + // clear any leftover selection + window.getSelection()?.removeAllRanges(); + + if (!wasCanceled) { + const parsed = parseVector(dom.textContent ?? ''); + if (parsed) { + apply(parsed); + flash(dom, 'flash-ok'); + } else { + flash(dom, 'flash-bad'); + } + } - tooltips.register(row, () => i18n.t('cursor.click-to-copy'), 'right'); + // restore the live display; the next prerender picks up any + // pose change resulting from the edit + dom.textContent = display; + }); return { - update: (display: string, copyText: string) => { - state.copyText = copyText; - if (state.display !== display) { - state.display = display; - if (performance.now() >= state.copiedUntil) { - value.text = display; - } + update: (text: string) => { + if (editing) { + return; + } + if (display !== text) { + display = text; + dom.textContent = text; } } }; }; - const positionRow = createRow('camera-info.position'); - const targetRow = createRow('camera-info.target'); + const positionRow = createRow('P', 'camera-info.position', (v) => { + const { target } = events.invoke('camera.getPose'); + events.fire('camera.setPose', { + position: new Vec3(v[0], v[1], v[2]), + target: new Vec3(target.x, target.y, target.z) + }); + }); + + const targetRow = createRow('T', 'camera-info.target', (v) => { + const { position } = events.invoke('camera.getPose'); + events.fire('camera.setPose', { + position: new Vec3(position.x, position.y, position.z), + target: new Vec3(v[0], v[1], v[2]) + }); + }); events.on('camera.showInfo', (visible: boolean) => { this.hidden = !visible; @@ -80,14 +154,8 @@ class CameraInfoOverlay extends Container { const { position, target } = events.invoke('camera.getPose'); - positionRow.update( - `${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`, - `${position.x},${position.y},${position.z}` - ); - targetRow.update( - `${target.x.toFixed(2)}, ${target.y.toFixed(2)}, ${target.z.toFixed(2)}`, - `${target.x},${target.y},${target.z}` - ); + positionRow.update(`${fmt(position.x)}, ${fmt(position.y)}, ${fmt(position.z)}`); + targetRow.update(`${fmt(target.x)}, ${fmt(target.y)}, ${fmt(target.z)}`); }); } } diff --git a/src/ui/editor.ts b/src/ui/editor.ts index 2e3f72ec..6c15d1a3 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -1,5 +1,5 @@ import { Container, Label } from '@playcanvas/pcui'; -import { Mat4, path, Vec3 } from 'playcanvas'; +import { Mat4, path } from 'playcanvas'; import { DataPanel } from './data-panel'; import { Events } from '../events'; @@ -80,38 +80,6 @@ class EditorUI { text: `SUPERSPLAT v${version}` }); - // cursor label - const cursorLabel = new Label({ - id: 'cursor-label' - }); - - let fullprecision = ''; - - events.on('camera.focalPointPicked', (details: { position: Vec3 }) => { - cursorLabel.text = `${details.position.x.toFixed(2)}, ${details.position.y.toFixed(2)}, ${details.position.z.toFixed(2)}`; - fullprecision = `${details.position.x}, ${details.position.y}, ${details.position.z}`; - }); - - ['pointerdown', 'pointerup', 'pointermove', 'wheel', 'dblclick'].forEach((eventName) => { - cursorLabel.dom.addEventListener(eventName, (event: Event) => event.stopPropagation()); - }); - - cursorLabel.dom.addEventListener('pointerdown', () => { - navigator.clipboard.writeText(fullprecision); - - const orig = cursorLabel.text; - cursorLabel.text = i18n.t('cursor.copied'); - setTimeout(() => { - cursorLabel.text = orig; - }, 1000); - }); - - // the camera info overlay occupies the same corner and its target row - // shows the focal point live, so hide the cursor label while it's visible - events.on('camera.showInfo', (visible: boolean) => { - cursorLabel.hidden = visible; - }); - // canvas container const canvasContainer = new Container({ id: 'canvas-container' @@ -138,7 +106,6 @@ class EditorUI { canvasContainer.dom.appendChild(canvas); canvasContainer.append(appLabel); - canvasContainer.append(cursorLabel); canvasContainer.append(cameraInfoOverlay); canvasContainer.append(toolsContainer); canvasContainer.append(scenePanel); @@ -180,8 +147,6 @@ class EditorUI { editorContainer.append(mainContainer); - tooltips.register(cursorLabel, () => i18n.t('cursor.click-to-copy'), 'top'); - // message popup const popup = new Popup(tooltips); diff --git a/src/ui/publish-settings-dialog.ts b/src/ui/publish-settings-dialog.ts index a271a020..de88bb53 100644 --- a/src/ui/publish-settings-dialog.ts +++ b/src/ui/publish-settings-dialog.ts @@ -287,7 +287,7 @@ class PublishSettingsDialog extends Container { overrideModelToggle.value = true; overrideAnimationToggle.value = hasPoses; animationToggle.value = hasPoses; - loopSelect.value = 'repeat'; + loopSelect.value = events.invoke('timeline.loop') ? 'repeat' : 'none'; colorPicker.value = [bgClr.r, bgClr.g, bgClr.b]; fovSlider.value = events.invoke('camera.fov'); generateLodsToggle.value = totalSplats >= 1_000_000 && isLargeScene; diff --git a/src/ui/scss/style.scss b/src/ui/scss/style.scss index d8ebaf0a..ec466a90 100644 --- a/src/ui/scss/style.scss +++ b/src/ui/scss/style.scss @@ -107,55 +107,59 @@ body { text-shadow: 1px 1px 4px black; } -#cursor-label { - position: absolute; - left: 12px; - bottom: 12px; - color: $text-primary; - text-shadow: 1px 1px 4px black; - - padding: 8px; - border-radius: 4px; - border: 1px solid transparent; - - cursor: pointer; - - &:hover { - border: 1px solid $clr-hilight; - background-color: rgba(0, 0, 0, 0.25); - } -} - #camera-info-overlay { position: absolute; left: 12px; bottom: 17px; - color: $text-primary; text-shadow: 1px 1px 4px black; + font-family: ui-monospace, Menlo, Consolas, monospace; .camera-info-row { display: flex; flex-direction: row; - padding: 2px 8px; + align-items: center; + padding: 1px 0; + font-family: inherit; + } + + .camera-info-key { + margin: 0; + color: $clr-icon-hilight; + font-family: inherit; + font-weight: bold; + overflow: visible; + } + + .camera-info-value { + margin: 0 0 0 8px; + color: $text-primary; + font-family: inherit; + font-variant-numeric: tabular-nums; + overflow: visible; + cursor: text; + user-select: text; + padding: 0 4px; border-radius: 4px; border: 1px solid transparent; - cursor: pointer; + outline: none; + transition: background-color 0.15s ease; &:hover { - border: 1px solid $clr-hilight; background-color: rgba(0, 0, 0, 0.25); } - } - .camera-info-key { - min-width: 56px; - margin-right: 0; - color: $text-secondary; - } + &:focus { + border-color: $clr-hilight; + background-color: rgba(0, 0, 0, 0.25); + } - .camera-info-value { - margin-left: 0; - font-variant-numeric: tabular-nums; + &.flash-ok { + background-color: rgba(120, 220, 140, 0.35); + } + + &.flash-bad { + background-color: rgba(220, 100, 100, 0.45); + } } } diff --git a/static/locales/de.json b/static/locales/de.json index fbb19812..0b158669 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "Modell überschreiben", "popup.publish.override-animation": "Animation überschreiben", "popup.publish.generate-lods": "LODs erzeugen", - "cursor.click-to-copy": "Klicken zum Kopieren", - "cursor.copied": "Kopiert!", - "camera-info.position": "Position:", - "camera-info.target": "Ziel:", + "camera-info.position": "Kameraposition", + "camera-info.target": "Kameraziel", "doc.reset": "Szene Zurücksetzen", "doc.unsaved-message": "Sie haben ungespeicherte Änderungen. Möchten Sie die Szene wirklich zurücksetzen?", "doc.reset-message": "Möchten Sie die Szene wirklich zurücksetzen?", diff --git a/static/locales/en.json b/static/locales/en.json index 8da60f9d..3ebd21a3 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "Override Model", "popup.publish.override-animation": "Override Animation", "popup.publish.generate-lods": "Generate LODs", - "cursor.click-to-copy": "Click to Copy", - "cursor.copied": "Copied!", - "camera-info.position": "Position:", - "camera-info.target": "Target:", + "camera-info.position": "Camera Position", + "camera-info.target": "Camera Target", "doc.reset": "Reset Scene", "doc.unsaved-message": "You have unsaved changes. Are you sure you want to reset the scene?", "doc.reset-message": "Are you sure you want to reset the scene?", diff --git a/static/locales/es.json b/static/locales/es.json index 310aae0d..409aa295 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "Reemplazar modelo", "popup.publish.override-animation": "Reemplazar animación", "popup.publish.generate-lods": "Generar LOD", - "cursor.click-to-copy": "Haga clic para copiar", - "cursor.copied": "¡Copiado!", - "camera-info.position": "Posición:", - "camera-info.target": "Objetivo:", + "camera-info.position": "Posición de la cámara", + "camera-info.target": "Objetivo de la cámara", "doc.reset": "Restablecer escena", "doc.unsaved-message": "Tiene cambios sin guardar. ¿Está seguro de que desea restablecer la escena?", "doc.reset-message": "¿Está seguro de que desea restablecer la escena?", diff --git a/static/locales/fr.json b/static/locales/fr.json index 8c26f0d6..8b198f36 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "Remplacer le modèle", "popup.publish.override-animation": "Remplacer l'animation", "popup.publish.generate-lods": "Générer les LOD", - "cursor.click-to-copy": "Cliquez pour copier", - "cursor.copied": "Copié!", - "camera-info.position": "Position :", - "camera-info.target": "Cible :", + "camera-info.position": "Position de la caméra", + "camera-info.target": "Cible de la caméra", "doc.reset": "Réinitialiser la Scène", "doc.unsaved-message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir réinitialiser la scène?", "doc.reset-message": "Êtes-vous sûr de vouloir réinitialiser la scène?", diff --git a/static/locales/ja.json b/static/locales/ja.json index 86dc59c9..0de6a56e 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "モデルを上書き", "popup.publish.override-animation": "アニメーションを上書き", "popup.publish.generate-lods": "LODを生成", - "cursor.click-to-copy": "クリックしてコピー", - "cursor.copied": "コピーしました!", - "camera-info.position": "位置:", - "camera-info.target": "注視点:", + "camera-info.position": "カメラ位置", + "camera-info.target": "カメラ注視点", "doc.reset": "シーンをリセット", "doc.unsaved-message": "保存されていない変更があります。シーンをリセットしてもよろしいですか?", "doc.reset-message": "シーンをリセットしてもよろしいですか?", diff --git a/static/locales/ko.json b/static/locales/ko.json index eea9ed5e..9865b174 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "모델 덮어쓰기", "popup.publish.override-animation": "애니메이션 덮어쓰기", "popup.publish.generate-lods": "LOD 생성", - "cursor.click-to-copy": "클릭하여 복사", - "cursor.copied": "복사됨!", - "camera-info.position": "위치:", - "camera-info.target": "대상:", + "camera-info.position": "카메라 위치", + "camera-info.target": "카메라 대상", "doc.reset": "장면 재설정", "doc.unsaved-message": "저장되지 않은 변경 사항이 있습니다. 장면을 재설정하시겠습니까?", "doc.reset-message": "장면을 재설정하시겠습니까?", diff --git a/static/locales/pt-BR.json b/static/locales/pt-BR.json index 3ca7b16d..ecc18014 100644 --- a/static/locales/pt-BR.json +++ b/static/locales/pt-BR.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "Substituir Modelo", "popup.publish.override-animation": "Substituir Animação", "popup.publish.generate-lods": "Gerar LODs", - "cursor.click-to-copy": "Clique para Copiar", - "cursor.copied": "Copiado!", - "camera-info.position": "Posição:", - "camera-info.target": "Alvo:", + "camera-info.position": "Posição da câmera", + "camera-info.target": "Alvo da câmera", "doc.reset": "Reiniciar a Cena", "doc.unsaved-message": "Você tem alterações não salvas. Tem certeza de que deseja reiniciar a cena?", "doc.reset-message": "Tem certeza de que deseja reiniciar a cena?", diff --git a/static/locales/ru.json b/static/locales/ru.json index 66fe855f..1c19323b 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "Заменить модель", "popup.publish.override-animation": "Заменить анимацию", "popup.publish.generate-lods": "Создавать LOD", - "cursor.click-to-copy": "Нажмите, чтобы скопировать", - "cursor.copied": "Скопировано!", - "camera-info.position": "Позиция:", - "camera-info.target": "Цель:", + "camera-info.position": "Позиция камеры", + "camera-info.target": "Цель камеры", "doc.reset": "Сбросить сцену", "doc.unsaved-message": "У вас есть несохранённые изменения. Вы уверены, что хотите сбросить сцену?", "doc.reset-message": "Вы уверены, что хотите сбросить сцену?", diff --git a/static/locales/zh-CN.json b/static/locales/zh-CN.json index b1837c53..eaae2dd8 100644 --- a/static/locales/zh-CN.json +++ b/static/locales/zh-CN.json @@ -252,10 +252,8 @@ "popup.publish.override-model": "覆盖模型", "popup.publish.override-animation": "覆盖动画", "popup.publish.generate-lods": "生成 LOD", - "cursor.click-to-copy": "点击复制", - "cursor.copied": "已复制!", - "camera-info.position": "位置:", - "camera-info.target": "目标:", + "camera-info.position": "相机位置", + "camera-info.target": "相机目标", "doc.reset": "重置场景", "doc.unsaved-message": "有未保存的更改。您确定要重置场景吗?", "doc.reset-message": "您确定要重置场景吗?",