+
+
- {{ timeSystemItem.timeSystem.name }}
+ {{ timeSystem.name }}
-
-
-
+
+
+
+
+
+
import _ from 'lodash';
-import { inject } from 'vue';
+import { useDragResizer } from 'utils/vue/useDragResizer.js';
+import { useFlexContainers } from 'utils/vue/useFlexContainers.js';
+import { inject, onBeforeUnmount, onMounted, provide, ref, toRaw, watch } from 'vue';
+import { TIME_CONTEXT_EVENTS } from '@/api/time/constants.js';
import SwimLane from '@/ui/components/swim-lane/SwimLane.vue';
+import ResizeHandle from '@/ui/layout/ResizeHandle/ResizeHandle.vue';
import TimelineAxis from '../../ui/components/TimeSystemAxis.vue';
import { useAlignment } from '../../ui/composables/alignmentContext.js';
import { getValidatedData, getValidatedGroups } from '../plan/util.js';
+import Container from './Container.js';
import ExtendedLinesOverlay from './ExtendedLinesOverlay.vue';
import TimelineObjectView from './TimelineObjectView.vue';
-const unknownObjectType = {
- definition: {
- cssClass: 'icon-object-unknown',
- name: 'Unknown Type'
- }
-};
-
const AXES_PADDING = 20;
-const PLOT_ITEM_H_PX = 100;
export default {
components: {
+ ResizeHandle,
TimelineObjectView,
TimelineAxis,
SwimLane,
ExtendedLinesOverlay
},
- inject: ['openmct', 'domainObject', 'path', 'composition', 'extendedLinesBus'],
+ props: {
+ isEditing: {
+ type: Boolean,
+ default: false
+ }
+ },
setup() {
+ const openmct = inject('openmct');
const domainObject = inject('domainObject');
const path = inject('path');
- const openmct = inject('openmct');
+
+ const items = ref([]);
+
+ // COMPOSABLE - Time Contexts
+ const timeSystem = ref([]);
+ const bounds = ref([]);
+ let timeContext;
+
+ // returned from composition api setup()
+ const setupTimeContexts = {
+ timeSystem,
+ bounds
+ };
+
+ onMounted(() => {
+ setTimeContext();
+ });
+
+ onBeforeUnmount(() => {
+ stopFollowingTimeContext();
+ });
+
+ function updateViewBounds() {
+ bounds.value = timeContext.getBounds();
+ updateContentHeight();
+ }
+
+ function setTimeSystemAndUpdateViewBounds(_timeSystem) {
+ timeSystem.value = _timeSystem;
+ updateViewBounds();
+ }
+
+ function setTimeContext() {
+ stopFollowingTimeContext();
+
+ timeContext = openmct.time.getContextForView(path);
+ const currentTimeSystem = timeContext.getTimeSystem();
+ setTimeSystemAndUpdateViewBounds(currentTimeSystem);
+ timeContext.on(TIME_CONTEXT_EVENTS.boundsChanged, updateViewBounds);
+ timeContext.on(TIME_CONTEXT_EVENTS.clockChanged, updateViewBounds);
+ timeContext.on(TIME_CONTEXT_EVENTS.timeSystemChanged, setTimeSystemAndUpdateViewBounds);
+ }
+
+ function stopFollowingTimeContext() {
+ if (timeContext) {
+ timeContext.off(TIME_CONTEXT_EVENTS.boundsChanged, updateViewBounds);
+ timeContext.off(TIME_CONTEXT_EVENTS.clockChanged, updateViewBounds);
+ timeContext.off(TIME_CONTEXT_EVENTS.timeSystemChanged, setTimeSystemAndUpdateViewBounds);
+ }
+ }
+
+ // COMPOSABLE - Content Height
+ const timelineHolder = ref(null);
+ const height = ref(null);
+ let handleContentResize;
+ let contentResizeObserver;
+
+ // returned from composition api setup()
+ const setupContentHeight = {
+ timelineHolder,
+ height
+ };
+
+ onMounted(() => {
+ handleContentResize = _.debounce(updateContentHeight, 500);
+ contentResizeObserver = new ResizeObserver(handleContentResize);
+ contentResizeObserver.observe(timelineHolder.value);
+ });
+
+ onBeforeUnmount(() => {
+ handleContentResize.cancel();
+ contentResizeObserver.disconnect();
+ });
+
+ function updateContentHeight() {
+ const clientHeight = getClientHeight();
+ if (height.value !== clientHeight) {
+ height.value = clientHeight;
+ }
+ calculateExtendedLinesLeftOffset();
+ }
+
+ function getClientHeight() {
+ let clientHeight = timelineHolder.value.getBoundingClientRect().height;
+
+ if (!clientHeight) {
+ //this is a hack - need a better way to find the parent of this component
+ let parent = timelineHolder.value.closest('.c-object-view');
+ if (parent) {
+ clientHeight = parent.getBoundingClientRect().height;
+ }
+ }
+
+ return clientHeight;
+ }
+
+ // COMPOSABLE - Composition
+ const composition = ref(null);
+ const isCompositionLoaded = ref(false);
+
+ const compositionCollection = openmct.composition.get(toRaw(domainObject));
+
+ onMounted(() => {
+ compositionCollection.on('add', addItem);
+ compositionCollection.on('remove', removeItem);
+ compositionCollection.on('reorder', reorder);
+ });
+
+ onBeforeUnmount(() => {
+ compositionCollection.off('add', addItem);
+ compositionCollection.off('remove', removeItem);
+ compositionCollection.off('reorder', reorder);
+ });
+
+ const setupComposition = {
+ composition,
+ isCompositionLoaded
+ };
+
+ // COMPOSABLE - Extended Lines
+ const extendedLinesBus = inject('extendedLinesBus');
+ const extendedLinesPerKey = ref({});
+ const extendedLinesLeftOffset = ref(0);
+ const extendedLineHover = ref({});
+ const extendedLineSelection = ref({});
+
const { alignment: alignmentData, reset: resetAlignment } = useAlignment(
domainObject,
path,
openmct
);
- return { alignmentData, resetAlignment };
- },
- data() {
- return {
- items: [],
- timeSystems: [],
- height: 0,
- useIndependentTime: this.domainObject.configuration.useIndependentTime === true,
- timeOptions: this.domainObject.configuration.timeOptions,
- extendedLinesPerKey: {},
- extendedLineHover: {},
- extendedLineSelection: {},
- extendedLinesLeftOffset: 0
+ // returned from composition api setup()
+ const setupExtendedLines = {
+ extendedLinesBus,
+ extendedLinesPerKey,
+ extendedLinesLeftOffset,
+ extendedLineHover,
+ extendedLineSelection,
+ calculateExtendedLinesLeftOffset
};
- },
- watch: {
- alignmentData: {
- handler() {
- this.calculateExtendedLinesLeftOffset();
- },
- deep: true
+
+ onMounted(() => {
+ openmct.selection.on('change', checkForLineSelection);
+ extendedLinesBus.addEventListener('update-extended-lines', updateExtendedLines);
+ extendedLinesBus.addEventListener('update-extended-hover', updateExtendedHover);
+ });
+
+ onBeforeUnmount(() => {
+ openmct.selection.off('change', checkForLineSelection);
+ extendedLinesBus.removeEventListener('update-extended-lines', updateExtendedLines);
+ extendedLinesBus.removeEventListener('update-extended-hover', updateExtendedHover);
+ resetAlignment();
+ });
+
+ watch(alignmentData, () => calculateExtendedLinesLeftOffset(), { deep: true });
+
+ function calculateExtendedLinesLeftOffset() {
+ extendedLinesLeftOffset.value = alignmentData.leftWidth + calculateSwimlaneOffset();
}
- },
- beforeUnmount() {
- this.resetAlignment();
- this.composition.off('add', this.addItem);
- this.composition.off('remove', this.removeItem);
- this.composition.off('reorder', this.reorder);
- this.stopFollowingTimeContext();
- this.handleContentResize.cancel();
- this.contentResizeObserver.disconnect();
- this.openmct.selection.off('change', this.checkForLineSelection);
- this.extendedLinesBus.removeEventListener('update-extended-lines', this.updateExtendedLines);
- this.extendedLinesBus.removeEventListener('update-extended-hover', this.updateExtendedHover);
- },
- mounted() {
- this.items = [];
- this.setTimeContext();
-
- this.extendedLinesBus.addEventListener('update-extended-lines', this.updateExtendedLines);
- this.extendedLinesBus.addEventListener('update-extended-hover', this.updateExtendedHover);
- this.openmct.selection.on('change', this.checkForLineSelection);
-
- if (this.composition) {
- this.composition.on('add', this.addItem);
- this.composition.on('remove', this.removeItem);
- this.composition.on('reorder', this.reorder);
- this.composition.load();
+
+ function calculateSwimlaneOffset() {
+ const firstSwimLane = timelineHolder.value.querySelector('.c-swimlane__lane-object');
+ if (firstSwimLane) {
+ const timelineHolderRect = timelineHolder.value.getBoundingClientRect();
+ const laneObjectRect = firstSwimLane.getBoundingClientRect();
+ const offset = laneObjectRect.left - timelineHolderRect.left;
+ const hasAxes = alignmentData.axes && Object.keys(alignmentData.axes).length > 0;
+ const swimLaneOffset = hasAxes ? offset + AXES_PADDING : offset;
+ return swimLaneOffset;
+ } else {
+ return 0;
+ }
}
- this.handleContentResize = _.debounce(this.handleContentResize, 500);
- this.contentResizeObserver = new ResizeObserver(this.handleContentResize);
- this.contentResizeObserver.observe(this.$refs.timelineHolder);
- },
- methods: {
- addItem(domainObject) {
- let type = this.openmct.types.get(domainObject.type) || unknownObjectType;
- let keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
- let objectPath = [domainObject].concat(this.path.slice());
- let rowCount = 0;
- if (domainObject.type === 'plan') {
- const planData = getValidatedData(domainObject);
- rowCount = getValidatedGroups(domainObject, planData).length;
- } else if (domainObject.type === 'gantt-chart') {
- rowCount = Object.keys(domainObject.configuration.swimlaneVisibility).length;
+ function updateExtendedLines(event) {
+ const { keyString, lines } = event.detail;
+ extendedLinesPerKey.value[keyString] = lines;
+ }
+ function updateExtendedHover(event) {
+ const { keyString, id } = event.detail;
+ extendedLineHover.value = { keyString, id };
+ }
+
+ function checkForLineSelection(selection) {
+ const selectionContext = selection?.[0]?.[0]?.context;
+ const eventType = selectionContext?.type;
+ if (eventType === 'time-strip-event-selection') {
+ const event = selectionContext.event;
+ const selectedObject = selectionContext.item;
+ const keyString = openmct.objects.makeKeyString(selectedObject.identifier);
+ extendedLineSelection.value = { keyString, id: event?.time };
+ } else {
+ extendedLineSelection.value = {};
}
- const isEventTelemetry = this.hasEventTelemetry(domainObject);
- let height =
- domainObject.type === 'telemetry.plot.stacked'
- ? `${domainObject.composition.length * PLOT_ITEM_H_PX}px`
- : 'auto';
- let item = {
+ }
+
+ // COMPOSABLE - Swimlane label width
+ const { x: swimLaneLabelWidth, mousedown } = useDragResizer({
+ initialX: domainObject.configuration.swimLaneLabelWidth,
+ callback: mutateSwimLaneLabelWidth
+ });
+
+ provide('swimLaneLabelWidth', swimLaneLabelWidth);
+ provide('mousedown', mousedown);
+
+ // returned from composition api setup()
+ const setupSwimLaneLabelWidth = {
+ changeSwimLaneLabelWidthContextAction
+ };
+
+ function mutateSwimLaneLabelWidth() {
+ openmct.objects.mutate(
domainObject,
+ 'configuration.swimLaneLabelWidth',
+ swimLaneLabelWidth.value
+ );
+ }
+
+ // context action called from outside component
+ function changeSwimLaneLabelWidthContextAction(size) {
+ swimLaneLabelWidth.value = size;
+ mutateSwimLaneLabelWidth();
+ }
+
+ // COMPOSABLE - flexible containers for swimlane vertical resizing
+ const existingContainers = [];
+
+ const {
+ addContainer,
+ removeContainer,
+ reorderContainers,
+ setContainers,
+ containers,
+ startContainerResizing,
+ containerResizing,
+ endContainerResizing,
+ toggleFixed,
+ sizeFixedContainer
+ } = useFlexContainers(timelineHolder, {
+ containers: domainObject.configuration.containers,
+ rowsLayout: true,
+ callback: mutateContainers
+ });
+
+ // returned from composition api setup()
+ const setupFlexContainers = {
+ containers,
+ startContainerResizing,
+ containerResizing,
+ endContainerResizing,
+ toggleFixedContextAction,
+ changeSizeContextAction
+ };
+
+ compositionCollection.load().then((loadedComposition) => {
+ composition.value = loadedComposition;
+ isCompositionLoaded.value = true;
+
+ // check if containers configuration matches composition
+ // in case composition has been modified outside of view
+ // if so, rebuild containers to match composition
+ // sync containers to composition,
+ // in case composition modified outside of view
+ // but do not mutate until user makes a change
+ let isConfigurationChanged = false;
+ composition.value.forEach((object, index) => {
+ const containerIndex = domainObject.configuration.containers.findIndex((container) =>
+ openmct.objects.areIdsEqual(container.domainObjectIdentifier, object.identifier)
+ );
+
+ if (containerIndex !== index) {
+ isConfigurationChanged = true;
+ }
+
+ if (containerIndex > -1) {
+ existingContainers.push(domainObject.configuration.containers[containerIndex]);
+ } else {
+ const container = new Container(object);
+ existingContainers.push(container);
+ }
+ });
+
+ // add check for total size not equal to 100? if comp and containers same, probably safe
+
+ if (isConfigurationChanged) {
+ setContainers(existingContainers);
+ }
+
+ setSelectionContext();
+ });
+
+ function setSelectionContext() {
+ const selection = openmct.selection.get()[0];
+ const selectionContext = selection?.[0]?.context;
+ const selectionDomainObject = selectionContext?.item;
+ const selectionType = selectionDomainObject?.type;
+
+ if (selectionType === 'time-strip') {
+ selectionContext.containers = containers.value;
+ selectionContext.swimLaneLabelWidth = swimLaneLabelWidth.value;
+ openmct.selection.select(selection);
+ }
+ }
+
+ function addItem(_domainObject) {
+ let rowCount = 0;
+
+ const typeKey = _domainObject.type;
+ const type = openmct.types.get(typeKey);
+ const keyString = openmct.objects.makeKeyString(_domainObject.identifier);
+ const objectPath = [_domainObject].concat(path.slice());
+
+ if (typeKey === 'plan') {
+ const planData = getValidatedData(_domainObject);
+ rowCount = getValidatedGroups(_domainObject, planData).length;
+ } else if (typeKey === 'gantt-chart') {
+ rowCount = Object.keys(_domainObject.configuration.swimlaneVisibility).length;
+ }
+ const isEventTelemetry = hasEventTelemetry(_domainObject);
+
+ const item = {
+ domainObject: _domainObject,
objectPath,
type,
keyString,
rowCount,
- height,
isEventTelemetry
};
- this.items.push(item);
- },
- hasEventTelemetry(domainObject) {
- const metadata = this.openmct.telemetry.getMetadata(domainObject);
+ items.value.push(item);
+
+ if (isCompositionLoaded.value) {
+ const container = new Container(domainObject);
+ addContainer(container);
+ }
+ }
+
+ function removeItem(identifier) {
+ const index = items.value.findIndex((item) =>
+ openmct.objects.areIdsEqual(identifier, item.domainObject.identifier)
+ );
+
+ items.value.splice(index, 1);
+ removeContainer(index);
+
+ delete extendedLinesPerKey.value[openmct.objects.makeKeyString(identifier)];
+ }
+
+ function reorder(reorderPlan) {
+ const oldItems = items.value.slice();
+ reorderPlan.forEach((reorderEvent) => {
+ items.value[reorderEvent.newIndex] = oldItems[reorderEvent.oldIndex];
+ });
+
+ reorderContainers(reorderPlan);
+ }
+
+ function hasEventTelemetry(_domainObject) {
+ const metadata = openmct.telemetry.getMetadata(_domainObject);
if (!metadata) {
return false;
}
@@ -190,124 +478,34 @@ export default {
const hasNoImages = !metadata.valuesForHints(['image']).length;
return hasDomain && hasNoRange && hasNoImages;
- },
- removeItem(identifier) {
- let index = this.items.findIndex((item) =>
- this.openmct.objects.areIdsEqual(identifier, item.domainObject.identifier)
- );
- this.items.splice(index, 1);
- delete this.extendedLinesPerKey[this.openmct.objects.makeKeyString(identifier)];
- },
- reorder(reorderPlan) {
- let oldItems = this.items.slice();
- reorderPlan.forEach((reorderEvent) => {
- this.items[reorderEvent.newIndex] = oldItems[reorderEvent.oldIndex];
- });
- },
- handleContentResize() {
- this.updateContentHeight();
- },
- updateContentHeight() {
- const clientHeight = this.getClientHeight();
- if (this.height !== clientHeight) {
- this.height = clientHeight;
- }
- this.calculateExtendedLinesLeftOffset();
- },
- getClientHeight() {
- let clientHeight = this.$refs.timelineHolder.getBoundingClientRect().height;
+ }
- if (!clientHeight) {
- //this is a hack - need a better way to find the parent of this component
- let parent = this.$el.closest('.c-object-view');
- if (parent) {
- clientHeight = parent.getBoundingClientRect().height;
- }
- }
+ function mutateContainers() {
+ openmct.objects.mutate(domainObject, 'configuration.containers', containers.value);
+ }
- return clientHeight;
- },
- getTimeSystems() {
- const timeSystems = this.openmct.time.getAllTimeSystems();
- timeSystems.forEach((timeSystem) => {
- this.timeSystems.push({
- timeSystem,
- bounds: this.getBoundsForTimeSystem(timeSystem)
- });
- });
- },
- getBoundsForTimeSystem(timeSystem) {
- const currentBounds = this.timeContext.getBounds();
-
- //TODO: Some kind of translation via an offset? of current bounds to target timeSystem
- return currentBounds;
- },
- updateViewBounds() {
- const bounds = this.timeContext.getBounds();
- this.updateContentHeight();
- let currentTimeSystemIndex = this.timeSystems.findIndex(
- (item) => item.timeSystem.key === this.openmct.time.getTimeSystem().key
- );
- if (currentTimeSystemIndex > -1) {
- let currentTimeSystem = {
- ...this.timeSystems[currentTimeSystemIndex]
- };
- currentTimeSystem.bounds = bounds;
- this.timeSystems.splice(currentTimeSystemIndex, 1, currentTimeSystem);
- }
- },
- setTimeContext() {
- this.stopFollowingTimeContext();
-
- this.timeContext = this.openmct.time.getContextForView(this.path);
- this.getTimeSystems();
- this.updateViewBounds();
- this.timeContext.on('boundsChanged', this.updateViewBounds);
- this.timeContext.on('clockChanged', this.updateViewBounds);
- },
- stopFollowingTimeContext() {
- if (this.timeContext) {
- this.timeContext.off('boundsChanged', this.updateViewBounds);
- this.timeContext.off('clockChanged', this.updateViewBounds);
- }
- },
- updateExtendedLines(event) {
- const { keyString, lines } = event.detail;
- this.extendedLinesPerKey[keyString] = lines;
- },
- updateExtendedHover(event) {
- const { keyString, id } = event.detail;
- this.extendedLineHover = { keyString, id };
- },
- checkForLineSelection(selection) {
- const selectionContext = selection?.[0]?.[0]?.context;
- const eventType = selectionContext?.type;
- if (eventType === 'time-strip-event-selection') {
- const event = selectionContext.event;
- const selectedObject = selectionContext.item;
- const keyString = this.openmct.objects.makeKeyString(selectedObject.identifier);
- this.extendedLineSelection = { keyString, id: event?.time };
- } else {
- this.extendedLineSelection = {};
- }
- },
- calculateExtendedLinesLeftOffset() {
- const swimLaneOffset = this.calculateSwimlaneOffset();
- this.extendedLinesLeftOffset = this.alignmentData.leftWidth + swimLaneOffset;
- },
- calculateSwimlaneOffset() {
- const firstSwimLane = this.$el.querySelector('.c-swimlane__lane-object');
- if (firstSwimLane) {
- const timelineHolderRect = this.$refs.timelineHolder.getBoundingClientRect();
- const laneObjectRect = firstSwimLane.getBoundingClientRect();
- const offset = laneObjectRect.left - timelineHolderRect.left;
- const hasAxes = this.alignmentData.axes && Object.keys(this.alignmentData.axes).length > 0;
- const swimLaneOffset = hasAxes ? offset + AXES_PADDING : offset;
- return swimLaneOffset;
- } else {
- return 0;
- }
+ // context action called from outside component
+ function toggleFixedContextAction(index, fixed) {
+ toggleFixed(index, fixed);
+ }
+
+ // context action called from outside component
+ function changeSizeContextAction(index, size) {
+ sizeFixedContainer(index, size);
}
+
+ return {
+ openmct,
+ domainObject,
+ path,
+ items,
+ ...setupComposition,
+ ...setupTimeContexts,
+ ...setupContentHeight,
+ ...setupExtendedLines,
+ ...setupSwimLaneLabelWidth,
+ ...setupFlexContainers
+ };
}
};
diff --git a/src/plugins/timeline/TimelineViewProvider.js b/src/plugins/timeline/TimelineViewProvider.js
index d6eb8a362aa..3bb43875d37 100644
--- a/src/plugins/timeline/TimelineViewProvider.js
+++ b/src/plugins/timeline/TimelineViewProvider.js
@@ -38,11 +38,12 @@ export default function TimelineViewProvider(openmct, extendedLinesBus) {
},
view: function (domainObject, objectPath) {
+ let component = null;
let _destroy = null;
return {
- show: function (element) {
- const { destroy } = mount(
+ show: function (element, isEditing) {
+ const { vNode, destroy } = mount(
{
el: element,
components: {
@@ -55,15 +56,30 @@ export default function TimelineViewProvider(openmct, extendedLinesBus) {
composition: openmct.composition.get(domainObject),
extendedLinesBus
},
- template: ''
+ data() {
+ return {
+ isEditing
+ };
+ },
+ template:
+ ''
},
{
app: openmct.app,
element
}
);
+ component = vNode.componentInstance;
_destroy = destroy;
},
+ contextAction(action, ...args) {
+ if (component?.$refs?.timeline?.[action]) {
+ component.$refs.timeline[action](...args);
+ }
+ },
+ onEditModeChange(isEditing) {
+ component.isEditing = isEditing;
+ },
destroy: function () {
if (_destroy) {
_destroy();
diff --git a/src/plugins/timeline/configuration.js b/src/plugins/timeline/configuration.js
new file mode 100644
index 00000000000..2006d5e0a20
--- /dev/null
+++ b/src/plugins/timeline/configuration.js
@@ -0,0 +1,39 @@
+/*****************************************************************************
+ * Open MCT, Copyright (c) 2014-2024, United States Government
+ * as represented by the Administrator of the National Aeronautics and Space
+ * Administration. All rights reserved.
+ *
+ * Open MCT is 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.
+ *
+ * Open MCT includes source code licensed under additional open source
+ * licenses. See the Open Source Licenses file (LICENSES.md) included with
+ * this source code distribution or the Licensing information page available
+ * at runtime from the About dialog for additional information.
+ *****************************************************************************/
+
+/**
+ * @typedef {Object} TimeStripConfig configuration for Time Strip views
+ * @property {boolean} useIndependentTime true for independent time, false for global time
+ * @property {Array} containers
+ * @property {number} swimLaneLabelWidth
+ */
+
+/**
+ * @returns {TimeStripConfig} configuration
+ */
+export default function getDefaultConfiguration() {
+ return {
+ useIndependentTime: false,
+ containers: [],
+ swimLaneLabelWidth: 200
+ };
+}
diff --git a/src/plugins/timeline/plugin.js b/src/plugins/timeline/plugin.js
index 380a66d9213..81266aa9490 100644
--- a/src/plugins/timeline/plugin.js
+++ b/src/plugins/timeline/plugin.js
@@ -20,11 +20,12 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
+import getDefaultConfiguration from './configuration.js';
import ExtendedLinesBus from './ExtendedLinesBus.js';
import TimelineCompositionPolicy from './TimelineCompositionPolicy.js';
+import TimelineElementsViewProvider from './TimelineElementsViewProvider.js';
import timelineInterceptor from './timelineInterceptor.js';
import TimelineViewProvider from './TimelineViewProvider.js';
-
const extendedLinesBus = new ExtendedLinesBus();
export { extendedLinesBus };
@@ -40,15 +41,14 @@ export default function () {
cssClass: 'icon-timeline',
initialize: function (domainObject) {
domainObject.composition = [];
- domainObject.configuration = {
- useIndependentTime: false
- };
+ domainObject.configuration = getDefaultConfiguration();
}
});
timelineInterceptor(openmct);
openmct.composition.addPolicy(new TimelineCompositionPolicy(openmct).allow);
openmct.objectViews.addProvider(new TimelineViewProvider(openmct, extendedLinesBus));
+ openmct.inspectorViews.addProvider(new TimelineElementsViewProvider(openmct));
}
install.extendedLinesBus = extendedLinesBus;
diff --git a/src/plugins/timeline/timeline.scss b/src/plugins/timeline/timeline.scss
index 53ef590d12a..cbfec691910 100644
--- a/src/plugins/timeline/timeline.scss
+++ b/src/plugins/timeline/timeline.scss
@@ -22,41 +22,60 @@
/********************************************* TIME STRIP */
.c-timeline-holder {
- overflow: hidden;
- position: relative;
- display: flex;
- flex-direction: column;
- gap: 1px;
-
- // Plot view overrides
- .gl-plot-display-area,
- .gl-plot-axis-area.gl-plot-y {
- bottom: $interiorMargin !important;
- }
+ overflow: auto;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ height: 100%;
+
+ // Plot view overrides
+ .gl-plot-display-area,
+ .gl-plot-axis-area.gl-plot-y {
+ bottom: $interiorMargin !important;
+ }
}
.c-timeline {
- &__objects {
- display: contents;
+ &__objects {
+ display: contents;
+
+ .c-swimlane {
+ overflow-x: hidden;
+ overflow-y: hidden;
+ }
- .c-swimlane {
- min-height: 100px; // TEMP!! Will be replaced when heights are set by user
- }
+ .c-object-view {
+ overflow-x: hidden;
+ overflow-y: scroll !important; // `scroll` ensures that right edges align in time
}
+ }
- &__overlay-lines {
- //background: rgba(deeppink, 0.2);
- @include abs();
- top: 20px; // Offset down to line up with time axis ticks line
- pointer-events: none; // Allows clicks to pass through
- z-index: 10; // Ensure it sits atop swimlanes
+ &__content {
+ &.--scales {
+ flex-grow: 1;
+ flex-shrink: 1;
}
- &__no-items {
- font-style: italic;
- position: absolute;
- left: $interiorMargin;
- top: 50%;
- transform: translateY(-50%);
+ &.--fixed {
+ flex-grow: 0;
+ flex-shrink: 0;
}
+ }
+
+ &__overlay-lines {
+ @include abs();
+ opacity: 0.5;
+ top: 20px; // Offset down to line up with time axis ticks line
+ pointer-events: none; // Allows clicks to pass through
+ z-index: 10; // Ensure it sits atop swimlanes
+ }
+
+ &__no-items {
+ font-style: italic;
+ position: absolute;
+ left: $interiorMargin;
+ top: 50%;
+ transform: translateY(-50%);
+ }
}
diff --git a/src/plugins/timeline/timelineInterceptor.js b/src/plugins/timeline/timelineInterceptor.js
index 3a59f5316f9..2df2b95bc3f 100644
--- a/src/plugins/timeline/timelineInterceptor.js
+++ b/src/plugins/timeline/timelineInterceptor.js
@@ -20,18 +20,25 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
+import getDefaultConfiguration from './configuration.js';
+
export default function timelineInterceptor(openmct) {
openmct.objects.addGetInterceptor({
appliesTo: (identifier, domainObject) => {
return domainObject && domainObject.type === 'time-strip';
},
invoke: (identifier, object) => {
+ const configuration = getDefaultConfiguration();
if (object && object.configuration === undefined) {
- object.configuration = {
- useIndependentTime: true
- };
+ object.configuration = configuration;
}
+ Object.keys(configuration).forEach((key) => {
+ if (object.configuration[key] === undefined) {
+ object.configuration[key] = configuration[key];
+ }
+ });
+
return object;
}
});
diff --git a/src/styles/_constants-darkmatter.scss b/src/styles/_constants-darkmatter.scss
index a2b122b9421..579ee57c6be 100644
--- a/src/styles/_constants-darkmatter.scss
+++ b/src/styles/_constants-darkmatter.scss
@@ -265,8 +265,6 @@ $editUIBaseColor: #344b8d; // Base color, toolbar bg
$editUIBaseColorHov: pullForward($editUIBaseColor, 20%);
$editUIBaseColorFg: #ffffff; // Toolbar button icon colors, etc.
$editUIAreaBaseColor: pullForward(saturate($editUIBaseColor, 30%), 20%);
-$editUIAreaShdw: $editUIAreaBaseColor 0 0 0 2px; // Edit area s-selected-parent
-$editUIAreaShdwSelected: $editUIAreaBaseColor 0 0 0 3px; // Edit area s-selected
$editUIGridColorBg: rgba($editUIBaseColor, 0.2); // Background of layout editing area
$editUIGridColorFg: rgba(#000, 0.1); // Grid lines in layout editing area
$editDimensionsColor: #6a5ea6;
diff --git a/src/styles/_constants-espresso.scss b/src/styles/_constants-espresso.scss
index 24d2531d07c..1be1c2a95e7 100644
--- a/src/styles/_constants-espresso.scss
+++ b/src/styles/_constants-espresso.scss
@@ -226,16 +226,11 @@ $borderMissing: 1px dashed $colorAlert !important;
$editUIColor: $uiColor; // Base color
$editUIColorBg: $editUIColor;
$editUIColorFg: #fff;
-$editUIColorHov: pullForward(
- saturate($uiColor, 10%),
- 10%
-); // Hover color when $editUIColor is applied as a base color
+$editUIColorHov: #ffffff; // Hover color when $editUIColor is applied as a base color
$editUIBaseColor: #344b8d; // Base color, toolbar bg
$editUIBaseColorHov: pullForward($editUIBaseColor, 20%);
$editUIBaseColorFg: #ffffff; // Toolbar button icon colors, etc.
-$editUIAreaBaseColor: pullForward(saturate($editUIBaseColor, 30%), 20%);
-$editUIAreaShdw: $editUIAreaBaseColor 0 0 0 2px; // Edit area s-selected-parent
-$editUIAreaShdwSelected: $editUIAreaBaseColor 0 0 0 3px; // Edit area s-selected
+$editUIAreaBaseColor: $editUIColor; //pullForward(saturate($editUIBaseColor, 30%), 20%);
$editUIGridColorBg: rgba($editUIBaseColor, 0.2); // Background of layout editing area
$editUIGridColorFg: rgba(#000, 0.1); // Grid lines in layout editing area
$editDimensionsColor: #6a5ea6;
diff --git a/src/styles/_constants-maelstrom.scss b/src/styles/_constants-maelstrom.scss
index 537394b033b..c920ffb7359 100644
--- a/src/styles/_constants-maelstrom.scss
+++ b/src/styles/_constants-maelstrom.scss
@@ -250,8 +250,6 @@ $editUIBaseColor: #344b8d; // Base color, toolbar bg
$editUIBaseColorHov: pullForward($editUIBaseColor, 20%);
$editUIBaseColorFg: #ffffff; // Toolbar button icon colors, etc.
$editUIAreaBaseColor: pullForward(saturate($editUIBaseColor, 30%), 20%);
-$editUIAreaShdw: $editUIAreaBaseColor 0 0 0 2px; // Edit area s-selected-parent
-$editUIAreaShdwSelected: $editUIAreaBaseColor 0 0 0 3px; // Edit area s-selected
$editUIGridColorBg: rgba($editUIBaseColor, 0.2); // Background of layout editing area
$editUIGridColorFg: rgba(#000, 0.1); // Grid lines in layout editing area
$editDimensionsColor: #6a5ea6;
diff --git a/src/styles/_constants-snow.scss b/src/styles/_constants-snow.scss
index f93cbab149d..ba50a073aea 100644
--- a/src/styles/_constants-snow.scss
+++ b/src/styles/_constants-snow.scss
@@ -233,8 +233,6 @@ $editUIBaseColor: #cae1ff; // Base color, toolbar bg
$editUIBaseColorHov: pushBack($editUIBaseColor, 20%);
$editUIBaseColorFg: #4c4c4c; // Toolbar button icon colors, etc.
$editUIAreaBaseColor: pullForward(saturate($editUIBaseColor, 30%), 20%);
-$editUIAreaShdw: $editUIAreaBaseColor 0 0 0 2px; // Edit area s-selected-parent
-$editUIAreaShdwSelected: $editUIAreaBaseColor 0 0 0 3px; // Edit area s-selected
$editUIGridColorBg: rgba($editUIBaseColor, 0.2); // Background of layout editing area
$editUIGridColorFg: rgba($editUIBaseColor, 0.3); // Grid lines in layout editing area
$editDimensionsColor: #d7aeff;
diff --git a/src/styles/_controls.scss b/src/styles/_controls.scss
index a25859d2c4d..aa40edb5094 100644
--- a/src/styles/_controls.scss
+++ b/src/styles/_controls.scss
@@ -87,6 +87,52 @@
}
}
+@mixin resizeHandleStyle($size: 1px, $margin: 3px) {
+ // Used by Flexible Layouts and Time Strip views
+ &:before {
+ // The visible resize line
+ background-color: $editUIColor;
+ content: '';
+ display: block;
+ @include abs();
+ min-height: $size;
+ min-width: $size;
+ }
+
+ &:hover {
+ &:before {
+ // The visible resize line
+ background-color: $editUIColorHov;
+ }
+ }
+
+ &.vertical {
+ // Resizes in Y dimension
+ padding: $margin $size;
+ &:before {
+ top: 50%;
+ bottom: auto;
+ transform: translateY(-50%);
+ }
+ &:hover {
+ cursor: row-resize;
+ }
+ }
+
+ &.horizontal {
+ // Resizes in X dimension
+ padding: $size $margin;
+ &:before {
+ left: 50%;
+ right: auto;
+ transform: translateX(-50%);
+ }
+ &:hover {
+ cursor: col-resize;
+ }
+ }
+}
+
/******************************************************** BUTTONS */
// Optionally can include icon in :before via markup
button {
diff --git a/src/ui/components/swim-lane/SwimLane.vue b/src/ui/components/swim-lane/SwimLane.vue
index 046a0829fd1..fe9cccfbcb0 100644
--- a/src/ui/components/swim-lane/SwimLane.vue
+++ b/src/ui/components/swim-lane/SwimLane.vue
@@ -23,8 +23,8 @@
@@ -55,7 +55,14 @@
@click="pressOnButton"
/>
+
+
.c-object-view {
+ border: 1px dashed transparent;
+ .is-editing & {
+ border-color: rgba($editUIAreaBaseColor, 0.6);
+ }
+
+ &[s-selected] {
+ .is-editing & {
+ border-color: $editUIAreaBaseColor
+ }
+ }
+ }
}
@include phonePortrait() {
@@ -290,6 +311,7 @@
flex: 1 1 auto !important;
height: 100%; // Chrome 73 overflow bug fix
overflow: auto;
+
> * + * {
margin-top: $interiorMargin;
}
@@ -376,25 +398,6 @@
}
}
-.is-editing {
- .l-shell__main-container {
- $m: 3px;
- box-shadow:
- $colorBodyBg 0 0 0 1px,
- $editUIAreaShdw;
- margin-left: $m;
- margin-right: $m;
- top: $shellToolBarH + $shellMainBrowseBarH + $interiorMarginLg !important;
-
- &[s-selected] {
- // Provide a clearer selection context articulation for the main edit area
- box-shadow:
- $colorBodyBg 0 0 0 1px,
- $editUIAreaShdwSelected;
- }
- }
-}
-
/************************** BROWSE BAR */
.l-browse-bar {
display: flex;
diff --git a/src/utils/vue/useDragResizer.js b/src/utils/vue/useDragResizer.js
new file mode 100644
index 00000000000..aa6bd69af73
--- /dev/null
+++ b/src/utils/vue/useDragResizer.js
@@ -0,0 +1,104 @@
+/*****************************************************************************
+ * Open MCT, Copyright (c) 2014-2024, United States Government
+ * as represented by the Administrator of the National Aeronautics and Space
+ * Administration. All rights reserved.
+ *
+ * Open MCT is 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.
+ *
+ * Open MCT includes source code licensed under additional open source
+ * licenses. See the Open Source Licenses file (LICENSES.md) included with
+ * this source code distribution or the Licensing information page available
+ * at runtime from the About dialog for additional information.
+ *****************************************************************************/
+
+import { ref } from 'vue';
+
+/**
+ * @typedef {Object} DragResizerOptions the options object
+ * @property {number} [initialX=0] the initial x of the object to track size for
+ * @property {number} [initialY=0] the initial y of the object to track size for
+ * @property {Function} [callback] the function to call when drag is complete
+ */
+
+/**
+ * @typedef {Object} ReturnObject the return object
+ * @property {number} x the reactive horizontal size during/post drag
+ * @property {number} y the reactive vertical size during/post drag
+ * @property {function} mousedown
+ */
+
+/**
+ * Defines a drag resizer hook that tracks the size of an object
+ * in vertical and horizontal direction on drag
+ * @param {DragResizerOptions} [param={}] the options object
+ * @returns {ReturnObject}
+ */
+export function useDragResizer({ initialX = 0, initialY = 0, callback } = {}) {
+ const x = ref(initialX);
+ const y = ref(initialY);
+ const isDragging = ref(false);
+
+ let dragStartX;
+ let dragStartY;
+ let dragStartClientX;
+ let dragStartClientY;
+
+ /**
+ * Begins the tracking process for the drag resizer
+ * and attaches mousemove and mousedown listeners to track size after drag completion
+ * Attach to a mousedown event for a draggable element
+ * @param {*} event the mousedown event
+ */
+ function mousedown(event) {
+ dragStartX = x.value;
+ dragStartY = y.value;
+ dragStartClientX = event.clientX;
+ dragStartClientY = event.clientY;
+ isDragging.value = true;
+
+ document.addEventListener('mouseup', mouseup, {
+ once: true,
+ capture: true
+ });
+ document.addEventListener('mousemove', mousemove);
+ event.preventDefault();
+ }
+
+ function mousemove(event) {
+ const deltaX = event.clientX - dragStartClientX;
+ const deltaY = event.clientY - dragStartClientY;
+
+ x.value = dragStartX + deltaX;
+ y.value = dragStartY + deltaY;
+ }
+
+ function mouseup(event) {
+ dragStartX = undefined;
+ dragStartY = undefined;
+ dragStartClientX = undefined;
+ dragStartClientY = undefined;
+ isDragging.value = false;
+
+ document.removeEventListener('mousemove', mousemove);
+ event.preventDefault();
+ event.stopPropagation();
+
+ callback?.();
+ }
+
+ return {
+ mousedown,
+ x,
+ y,
+ isDragging
+ };
+}
diff --git a/src/utils/vue/useFlexContainers.js b/src/utils/vue/useFlexContainers.js
new file mode 100644
index 00000000000..34ff0f8c0ce
--- /dev/null
+++ b/src/utils/vue/useFlexContainers.js
@@ -0,0 +1,293 @@
+/*****************************************************************************
+ * Open MCT, Copyright (c) 2014-2024, United States Government
+ * as represented by the Administrator of the National Aeronautics and Space
+ * Administration. All rights reserved.
+ *
+ * Open MCT is 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.
+ *
+ * Open MCT includes source code licensed under additional open source
+ * licenses. See the Open Source Licenses file (LICENSES.md) included with
+ * this source code distribution or the Licensing information page available
+ * at runtime from the About dialog for additional information.
+ *****************************************************************************/
+
+import { computed, ref } from 'vue';
+
+/**
+ * @typedef {Object} configuration
+ * @property {boolean} rowsLayout true if containers arranged as rows, false if columns
+ * @property {number} minContainerSize minimum size in pixels of a container
+ * @property {Function} callback function to call when container resize completes
+ */
+
+/**
+ * Provides a means to size a collection of containers to a total size of 100%.
+ * The containers will resize proportionally to fit the total size on add/remove.
+ * The containers will initially be sized based on their scale property.
+ * @param {import('vue').Ref {
+ return containers.value
+ .filter((container) => container.fixed === true)
+ .reduce((size, currentContainer) => size + currentContainer.size, 0);
+ });
+
+ function addContainer(container) {
+ containers.value.push(container);
+
+ sizeItems(containers.value);
+ roundExcess(containers.value);
+ callback?.();
+ }
+
+ function removeContainer(index) {
+ const isFlexContainer = !containers.value[index].fixed;
+
+ containers.value.splice(index, 1);
+
+ if (isFlexContainer) {
+ sizeItems(containers.value);
+ roundExcess(containers.value);
+ }
+
+ callback?.();
+ }
+
+ function reorderContainers(reorderPlan) {
+ const oldContainers = containers.value.slice();
+
+ reorderPlan.forEach((reorderEvent) => {
+ containers.value[reorderEvent.newIndex] = oldContainers[reorderEvent.oldIndex];
+ });
+
+ callback?.();
+ }
+
+ function setContainers(_containers) {
+ containers.value = _containers;
+ sizeItems(containers.value);
+ roundExcess(containers.value);
+ }
+
+ function startContainerResizing(index) {
+ const beforeContainer = getBeforeContainer(index);
+ const afterContainer = getAfterContainer(index);
+
+ if (beforeContainer && afterContainer && !beforeContainer.fixed && !afterContainer.fixed) {
+ maxMoveSize.value = beforeContainer.size + afterContainer.size;
+ }
+ }
+
+ function getBeforeContainer(index) {
+ return containers.value
+ .slice(0, index + 1)
+ .filter((container) => !container.fixed === true)
+ .at(-1);
+ }
+
+ function getAfterContainer(index) {
+ return containers.value.slice(index + 1).filter((container) => !container.fixed === true)[0];
+ }
+
+ function containerResizing(index, delta, event) {
+ const beforeContainer = getBeforeContainer(index);
+ const afterContainer = getAfterContainer(index);
+ const percentageMoved = Math.round((delta / getElSize()) * 100);
+
+ if (beforeContainer && afterContainer && !beforeContainer.fixed && !afterContainer.fixed) {
+ beforeContainer.size = getContainerSize(beforeContainer.size + percentageMoved);
+ afterContainer.size = getContainerSize(afterContainer.size - percentageMoved);
+ } else {
+ console.warn(
+ 'Drag requires two flexible containers. Use Elements Tab in Inspector to resize.'
+ );
+ }
+ }
+
+ function endContainerResizing() {
+ callback?.();
+ }
+
+ function getElSize() {
+ const elSize = rowsLayout === true ? element.value.offsetHeight : element.value.offsetWidth;
+ // TODO FIXME temporary patch for timeline
+ const timelineHeight = 32;
+
+ return elSize - fixedContainersSize.value - timelineHeight;
+ }
+
+ function getContainerSize(size) {
+ if (size < minContainerSize) {
+ return minContainerSize;
+ } else if (size > maxMoveSize.value - minContainerSize) {
+ return maxMoveSize.value - minContainerSize;
+ } else {
+ return size;
+ }
+ }
+
+ /**
+ * Resize flexible sized items so they fit proportionally within a viewport
+ * 1. add size to 0 sized items based on scale proportional to total scale
+ * 2. resize item sizes to equal 100
+ * if total size < 100, resize all items
+ * if total size > 100, resize only items not resized in step 1 (newly added)
+ *
+ * Items may have a scale (ie. items with composition)
+ *
+ * Handles single add or removal, as well as atypical use cases,
+ * such as composition out of sync with containers config
+ * due to composition edits outside of view
+ *
+ * Typically roundExcess is called afterwards to limit pixels and percents to integers
+ *
+ * @param {*} items
+ */
+ function sizeItems(items) {
+ let totalSize;
+ const flexItems = items.filter((item) => !item.fixed);
+
+ if (flexItems.length === 0) {
+ return;
+ }
+
+ if (flexItems.length === 1) {
+ flexItems[0].size = 100;
+ return;
+ }
+
+ const flexItemsWithSize = flexItems.filter((item) => item.size);
+ const flexItemsWithoutSize = flexItems.filter((item) => !item.size);
+ // total number of flexible items, adjusted by each item scale
+ const totalScale = flexItems.reduce((total, item) => {
+ const scale = item.scale ?? 1;
+ return total + scale;
+ }, 0);
+
+ flexItemsWithoutSize.forEach((item) => {
+ const scale = item.scale ?? 1;
+ item.size = Math.round((100 * scale) / totalScale);
+ });
+
+ totalSize = flexItems.reduce((total, item) => total + item.size, 0);
+
+ if (totalSize > 100) {
+ const addedSize = flexItemsWithoutSize.reduce((total, item) => total + item.size, 0);
+ const remainingSize = 100 - addedSize;
+
+ flexItemsWithSize.forEach((item) => {
+ item.size = Math.round((item.size * remainingSize) / 100);
+ });
+ } else if (totalSize < 100) {
+ flexItems.forEach((item) => {
+ item.size = Math.round((item.size * 100) / totalSize);
+ });
+ }
+ }
+
+ /**
+ *
+ * Rounds excess and applies to one of the items
+ * if an optional index is not specified, excess applied to last item
+ *
+ * @param {*} items
+ * @param {Number} (optional) index of the item to apply excess to in the event of rounding errors
+ */
+ function roundExcess(items, specifiedIndex) {
+ const flexItems = items.filter((item) => !item.fixed);
+
+ if (!flexItems.length) {
+ return;
+ }
+
+ const totalSize = flexItems.reduce((total, item) => total + item.size, 0);
+ const excess = Math.round(100 - totalSize);
+ let index;
+
+ if (specifiedIndex !== undefined && items[specifiedIndex] && !items[specifiedIndex].fixed) {
+ index = specifiedIndex;
+ }
+
+ if (index === undefined) {
+ index = items.findLastIndex((item) => !item.fixed);
+ }
+
+ if (index > -1) {
+ items[index].size += excess;
+ }
+ }
+
+ function toggleFixed(index, fixed) {
+ let addExcessToContainer;
+ const remainingItems = containers.value.slice();
+ const container = remainingItems.splice(index, 1)[0];
+
+ if (container.fixed !== fixed) {
+ if (fixed) {
+ // toggle flex to fixed
+ container.size = Math.round((container.size / 100) * getElSize());
+ container.fixed = fixed;
+ sizeItems(remainingItems);
+ } else {
+ // toggle fixed to flex
+ addExcessToContainer = index;
+ container.size = Math.round((container.size * 100) / (getElSize() + container.size));
+ const remainingSize = 100 - container.size;
+
+ remainingItems
+ .filter((item) => !item.fixed)
+ .forEach((item) => {
+ item.size = Math.round((item.size * remainingSize) / 100);
+ });
+
+ container.fixed = fixed;
+ }
+
+ roundExcess(containers.value, addExcessToContainer);
+ callback?.();
+ }
+ }
+
+ function sizeFixedContainer(index, size) {
+ const container = containers.value[index];
+
+ if (container.fixed) {
+ container.size = size;
+
+ callback?.();
+ } else {
+ console.warn('Use view drag resizing to resize flexible containers.');
+ }
+ }
+
+ return {
+ addContainer,
+ removeContainer,
+ reorderContainers,
+ setContainers,
+ containers,
+ startContainerResizing,
+ containerResizing,
+ endContainerResizing,
+ toggleFixed,
+ sizeFixedContainer
+ };
+}
diff --git a/src/utils/vue/useIsEditing.js b/src/utils/vue/useIsEditing.js
new file mode 100644
index 00000000000..27f6ed9b767
--- /dev/null
+++ b/src/utils/vue/useIsEditing.js
@@ -0,0 +1,43 @@
+/*****************************************************************************
+ * Open MCT, Copyright (c) 2014-2024, United States Government
+ * as represented by the Administrator of the National Aeronautics and Space
+ * Administration. All rights reserved.
+ *
+ * Open MCT is 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.
+ *
+ * Open MCT includes source code licensed under additional open source
+ * licenses. See the Open Source Licenses file (LICENSES.md) included with
+ * this source code distribution or the Licensing information page available
+ * at runtime from the About dialog for additional information.
+ *****************************************************************************/
+
+import { onBeforeUnmount, onMounted, ref } from 'vue';
+
+export default function useIsEditing(openmct) {
+ const isEditing = ref(openmct.editor.isEditing());
+
+ onMounted(() => {
+ openmct.editor.on('isEditing', setIsEditing);
+ });
+
+ onBeforeUnmount(() => {
+ openmct.editor.off('isEditing', setIsEditing);
+ });
+
+ function setIsEditing(_isEditing) {
+ isEditing.value = _isEditing;
+ }
+
+ return {
+ isEditing
+ };
+}