Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DIY widget #1546

Merged
merged 3 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"leaflet": "1.9.3",
"localforage": "^1.10.0",
"mathjs": "^13.0.3",
"monaco-editor": "^0.52.0",
"pinia": "^2.0.13",
"posthog-js": "^1.194.3",
"roboto-fontface": "*",
Expand All @@ -66,6 +67,7 @@
"uuid": "^8.3.2",
"vue": "^3.4.21",
"vue-draggable-plus": "^0.2.0-beta.2",
"vue-draggable-resizable": "3.0.0",
"vue-router": "^4.0.14",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue3-slider": "^1.9.0",
Expand Down
Binary file added src/assets/widgets/DoItYourself.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/components/EditMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ import CompassImg from '@/assets/widgets/Compass.png'
import CompassHUDImg from '@/assets/widgets/CompassHUD.png'
import CustomWidgetBaseImg from '@/assets/widgets/CustomWidgetBase.png'
import DepthHUDImg from '@/assets/widgets/DepthHUD.png'
import DoItYourselfImg from '@/assets/widgets/DoItYourself.png'
import IFrameImg from '@/assets/widgets/IFrame.png'
import ImageViewImg from '@/assets/widgets/ImageView.png'
import MapImg from '@/assets/widgets/Map.png'
Expand Down Expand Up @@ -804,6 +805,7 @@ const widgetImages = {
CompassHUD: CompassHUDImg,
CustomWidgetBase: CustomWidgetBaseImg,
DepthHUD: DepthHUDImg,
DoItYourself: DoItYourselfImg,
IFrame: IFrameImg,
ImageView: ImageViewImg,
Map: MapImg,
Expand Down
340 changes: 340 additions & 0 deletions src/components/widgets/DoItYourself.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="main">
<div class="w-full h-full" v-html="compiledCode" />
</div>
<v-dialog
v-model="widgetStore.widgetManagerVars(widget.hash).configMenuOpen"
:max-width="interfaceStore.isOnSmallScreen ? '100%' : '800px'"
@after-enter="handleDialogOpening"
@after-leave="handleDialogClosing"
>
<vue-draggable-resizable :drag-handle="'.drag-handle'" w="auto" h="auto" :handles="['tm', 'mr', 'bm', 'ml']">
<v-card class="pa-2" :style="interfaceStore.globalGlassMenuStyles">
<v-icon class="drag-handle absolute top-[12px] left-[12px] cursor-grab">mdi-drag</v-icon>
<v-icon class="absolute top-[12px] right-[12px] cursor-pointer" @click="showHelp = !showHelp">
mdi-help-circle-outline
</v-icon>
<v-card-title class="w-full text-center mt-2 mb-2">DIY widget configuration</v-card-title>
<v-card-text class="mx-2 flex flex-col gap-y-3">
<v-expand-transition>
<div v-if="showHelp" class="help-panel mb-4 p-4 rounded bg-white/5">
<h3 class="text-lg mb-2">Editor instructions</h3>
<ul class="text-sm text-white/70 list-disc pl-4 space-y-1">
<li>Use the HTML, CSS, and JS editors to create your custom widget</li>
<li>Changes are applied when you click Apply or press Cmd/Ctrl + Enter/S</li>
<li>Navigate between editors using Cmd/Ctrl + Option/Alt + ↑/↓</li>
<li>Reset to last saved state using the Reset button</li>
<li>Your code runs in the widget's context and has access to the DOM</li>
<li>You can use the console to debug your code</li>
<li>
You can use the data-lake system to inject or consume data from Cockpit. Check the docs for more
information around this.
</li>
<li>Click on each editor's header to expand it to full size</li>
</ul>
</div>
</v-expand-transition>

<v-expansion-panels v-model="expandedPanel" class="editors-container" multiple>
<v-expansion-panel value="html">
<v-expansion-panel-title static height="36px" class="text-white/60"> HTML </v-expansion-panel-title>
<v-expansion-panel-text eager>
<div ref="htmlEditorContainer" class="editor-container" :style="{ height: editorHeight }" />
</v-expansion-panel-text>
</v-expansion-panel>

<v-expansion-panel value="js">
<v-expansion-panel-title static height="36px" class="text-white/60"> JS </v-expansion-panel-title>
<v-expansion-panel-text eager>
<div ref="jsEditorContainer" class="editor-container" :style="{ height: editorHeight }" />
</v-expansion-panel-text>
</v-expansion-panel>

<v-expansion-panel value="css">
<v-expansion-panel-title static height="36px" class="text-white/60"> CSS </v-expansion-panel-title>
<v-expansion-panel-text eager>
<div ref="cssEditorContainer" class="editor-container" :style="{ height: editorHeight }" />
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-card-actions>
<div class="flex justify-between items-center px-4 w-full h-full">
<v-btn class="text-white/60" variant="text" @click="closeDialog">Close</v-btn>
<div class="flex gap-x-10">
<v-btn class="text-white/60" variant="text" @click="resetChanges">Reset</v-btn>
<v-btn class="text-white" variant="text" @click="applyChanges">Apply</v-btn>
</div>
</div>
</v-card-actions>
</v-card>
</vue-draggable-resizable>
</v-dialog>
</template>

<script setup lang="ts">
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { computed, onBeforeMount, onBeforeUnmount, onMounted, ref, toRefs } from 'vue'

import { useAppInterfaceStore } from '@/stores/appInterface'
import { useWidgetManagerStore } from '@/stores/widgetManager'
import type { Widget } from '@/types/widgets'

self.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'json') {
return new jsonWorker()
}
if (label === 'css' || label === 'scss' || label === 'less') {
return new cssWorker()
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new htmlWorker()
}
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

const interfaceStore = useAppInterfaceStore()
const widgetStore = useWidgetManagerStore()

const props = defineProps<{
/**
* Widget reference
*/
widget: Widget
}>()

const widget = toRefs(props).widget
const htmlEditorContainer = ref<HTMLElement | null>(null)
const cssEditorContainer = ref<HTMLElement | null>(null)
const jsEditorContainer = ref<HTMLElement | null>(null)
const showHelp = ref(false)
const expandedPanel = ref<string[]>(['html', 'css', 'js'])
const editorHeightVh = 50
let htmlEditor: monaco.editor.IStandaloneCodeEditor | null = null
let cssEditor: monaco.editor.IStandaloneCodeEditor | null = null
let jsEditor: monaco.editor.IStandaloneCodeEditor | null = null

const defaultOptions = {
html: `<!-- Write your HTML code here -->
<div id="diy-container">
<span>Create your own widget!</span>
</div>`,
css: `/* Write your CSS code here */
#diy-container {
width: 100%;
height: 100%;
padding: 1rem;
background-color: white;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
text-align: center;
font-family: 'Roboto', sans-serif;
font-weight: 500;
}`,
js: `// Write your JavaScript code here
document.addEventListener('DOMContentLoaded', () => {
// Your code here
});`,
}

/* eslint-disable no-useless-escape */
const compiledCode = computed(() => {
const html = widget.value.options.html || defaultOptions.html
const css = widget.value.options.css || defaultOptions.css
const js = widget.value.options.js || defaultOptions.js

return `${html}
<style>
${css}
</style>
<script>
${js}
<\/script>`
})
/* eslint-enable jsdoc/require-jsdoc */

const createEditor = (container: HTMLElement, language: string, value: string): monaco.editor.IStandaloneCodeEditor => {
return monaco.editor.create(container, {
value,
language,
theme: 'vs-dark',
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
padding: { top: 12, bottom: 12 },
})
}

const addKeyboardShortcuts = (editor: monaco.editor.IStandaloneCodeEditor): void => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => applyChanges())
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => applyChanges())

// Add shortcuts to move between editors
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.UpArrow, () => {
if (htmlEditor && htmlEditor.hasTextFocus() && cssEditor) cssEditor.focus()
else if (jsEditor && jsEditor.hasTextFocus() && htmlEditor) htmlEditor.focus()
else if (cssEditor && cssEditor.hasTextFocus() && jsEditor) jsEditor.focus()
})

editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.DownArrow, () => {
if (htmlEditor && htmlEditor.hasTextFocus() && jsEditor) jsEditor.focus()
else if (jsEditor && jsEditor.hasTextFocus() && cssEditor) cssEditor.focus()
else if (cssEditor && cssEditor.hasTextFocus() && htmlEditor) htmlEditor.focus()
})
}

const initEditor = async (): Promise<void> => {
if (htmlEditor || !htmlEditorContainer.value) return
if (jsEditor || !jsEditorContainer.value) return
if (cssEditor || !cssEditorContainer.value) return

htmlEditor = createEditor(htmlEditorContainer.value, 'html', widget.value.options.html || defaultOptions.html)
jsEditor = createEditor(jsEditorContainer.value, 'javascript', widget.value.options.js || defaultOptions.js)
cssEditor = createEditor(cssEditorContainer.value, 'css', widget.value.options.css || defaultOptions.css)

// Add keyboard shortcuts to all editors
if (htmlEditor) addKeyboardShortcuts(htmlEditor)
if (jsEditor) addKeyboardShortcuts(jsEditor)
if (cssEditor) addKeyboardShortcuts(cssEditor)
}

const handleDialogOpening = async (): Promise<void> => {
await initEditor()
}

const handleDialogClosing = async (): Promise<void> => {
finishEditor()
}

const applyChanges = (): void => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned on the functionality review, a ctrl+enter shortcut for this apply function would be very handy.

if (!htmlEditor || !cssEditor || !jsEditor) return
widget.value.options.html = htmlEditor.getValue()
widget.value.options.css = cssEditor.getValue()
widget.value.options.js = jsEditor.getValue()
executeUserScript()
}

const executeUserScript = (): void => {
const js = widget.value.options.js || ''
const scriptElementId = `diy-script-${widget.value.hash}`

// Remove existing script element
document.getElementById(scriptElementId)?.remove()

// Create new script element
const scriptEl = document.createElement('script')
scriptEl.type = 'text/javascript'
scriptEl.textContent = js
scriptEl.id = scriptElementId
document.body.appendChild(scriptEl)
}

const resetChanges = (): void => {
if (!htmlEditor || !cssEditor || !jsEditor) return
htmlEditor.setValue(widget.value.options.html || defaultOptions.html)
cssEditor.setValue(widget.value.options.css || defaultOptions.css)
jsEditor.setValue(widget.value.options.js || defaultOptions.js)
}

const finishEditor = (): void => {
if (htmlEditor) {
htmlEditor.dispose()
htmlEditor = null
}
if (cssEditor) {
cssEditor.dispose()
cssEditor = null
}
if (jsEditor) {
jsEditor.dispose()
jsEditor = null
}
}

const closeDialog = (): void => {
widgetStore.widgetManagerVars(widget.value.hash).configMenuOpen = false
finishEditor()
}

// Add computed property for editor heights
const editorHeight = computed(() => {
const openPanels = expandedPanel.value.length
if (openPanels === 0) return `${editorHeightVh}vh`
return `${Math.round(editorHeightVh / openPanels)}vh` // Divide the total height by number of open panels
})

onBeforeMount(() => {
widget.value.options = Object.assign({}, defaultOptions, widget.value.options)
})

onMounted(() => {
executeUserScript()
})

onBeforeUnmount(() => {
finishEditor()
})
</script>

<style scoped>
.main {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
min-width: 150px;
min-height: 200px;
}

.editors-container {
width: 100%;
height: 100%;
background: transparent !important;
overflow-y: auto;
}

.editor-container {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.1);
height: 100%;
transition: height 0.3s ease;
}

.editor-expanded {
height: v-bind(editorHeight);
}

:deep(.v-expansion-panel) {
background: transparent !important;
}

:deep(.v-expansion-panel-title) {
padding: 8px 16px;
min-height: unset;
}

:deep(.v-expansion-panel-text__wrapper) {
padding: 0;
}
</style>
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Sentry from '@sentry/vue'
import FloatingVue from 'floating-vue'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import VueDraggableResizable from 'vue-draggable-resizable'
import VueVirtualScroller from 'vue-virtual-scroller'

import { app_version } from '@/libs/cosmos'
Expand Down Expand Up @@ -47,6 +48,7 @@ if (window.localStorage.getItem('cockpit-enable-usage-statistics-telemetry') &&
}

app.component('FontAwesomeIcon', FontAwesomeIcon)
app.component('VueDraggableResizable', VueDraggableResizable)
app.use(router).use(vuetify).use(createPinia()).use(FloatingVue).use(VueVirtualScroller)
app.mount('#app')

Expand Down
6 changes: 6 additions & 0 deletions src/types/shims.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,9 @@ declare module 'vue-virtual-scroller' {

export default plugin
}

declare module 'vue-draggable-resizable' {
import { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, never>>
export default component
}
Loading
Loading