Files
AI-Studio/app/MindWork AI Studio/wwwroot/app.js
T

403 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.generateDiff = function (text1, text2, divDiff, divLegend) {
let wikEdDiff = new WikEdDiff();
let targetDiv = document.getElementById(divDiff)
if (!targetDiv) {
return;
}
targetDiv.innerHTML = wikEdDiff.diff(text1, text2);
targetDiv.classList.add('mud-typography-body1', 'improvedDiff');
let legend = document.getElementById(divLegend);
if (!legend) {
return;
}
legend.innerHTML = `
<div class="legend mt-2">
<h3>Legend</h3>
<ul class="mt-2">
<li><span class="wikEdDiffMarkRight" title="Moved block" id="wikEdDiffMark999" onmouseover="wikEdDiffBlockHandler(undefined, this, 'mouseover');"></span> Original block position</li>
<li><span title="+" class="wikEdDiffInsert">Inserted<span class="wikEdDiffSpace"><span class="wikEdDiffSpaceSymbol"></span> </span>text<span class="wikEdDiffNewline"> </span></span></li>
<li><span title="−" class="wikEdDiffDelete">Deleted<span class="wikEdDiffSpace"><span class="wikEdDiffSpaceSymbol"></span> </span>text<span class="wikEdDiffNewline"> </span></span></li>
<li><span class="wikEdDiffBlockLeft" title="◀" id="wikEdDiffBlock999" onmouseover="wikEdDiffBlockHandler(undefined, this, 'mouseover');">Moved<span class="wikEdDiffSpace"><span class="wikEdDiffSpaceSymbol"></span> </span>block<span class="wikEdDiffNewline"> </span></span></li>
</ul>
</div>
`;
}
window.clearDiv = function (divName) {
let targetDiv = document.getElementById(divName);
if (!targetDiv) {
return;
}
targetDiv.innerHTML = '';
}
// We add a click handler to the provider group headers so that clicking anywhere on the header expands or collapses the group.
// Right now (August 2026), this is not possible using MudBlazor.
document.addEventListener('click', function (event) {
const target = event.target
if (!(target instanceof Element))
return
const groupHeaderRow = target.closest('tr')
if (!groupHeaderRow?.querySelector(':scope > .provider-group-header'))
return
if (target.closest('.mud-table-row-expander'))
return
groupHeaderRow.querySelector('.mud-table-row-expander')?.click()
})
window.scrollToBottom = function(element) {
element.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
}
// Scrolls back to where a sticky header sits in the flow, so that the content below it starts right under the
// header. The anchor is an empty element right before the header: once the page is scrolled past it, the anchor
// lies above the top of its scrolling container, which then moves back by that distance. Otherwise, the content
// below already starts right under the header, and nothing moves.
//
// It sets scrollTop itself: scrollIntoView did not move the page for this anchor without height, and the content
// which renders right after a switch may stop a smooth scroll.
window.scrollToStickyHeader = function(anchor) {
if (!anchor)
return;
// The nearest ancestor which scrolls its content, or else the document:
let container = anchor.parentElement;
while (container && !['auto', 'scroll'].includes(getComputedStyle(container).overflowY))
container = container.parentElement;
const containerTop = container ? container.getBoundingClientRect().top + container.clientTop : 0;
container ??= document.scrollingElement;
const distance = anchor.getBoundingClientRect().top - containerTop;
if (distance < 0)
container.scrollTop += distance;
}
// Lets content further down stick right below a sticky header, e.g., a column beside the content. The scrolling
// container of the header gets two CSS variables: --sticky-header-height, the height of the header, and
// --sticky-space-below-header, the height of the container which the header leaves free. Both follow every change
// of size, e.g., when a narrower window wraps the header into more lines.
//
// A header which left the page stops being observed with the next change of size, so nothing needs to stop it.
function updateStickyHeaderVariables(header) {
const container = header.parentElement;
if (!header.isConnected || !container)
return false;
// The exact height, since offsetHeight rounds to whole pixels: a header rounded down covers the topmost line of
// the content sticking below it, such as the border of a box.
const headerHeight = header.getBoundingClientRect().height;
container.style.setProperty('--sticky-header-height', `${headerHeight}px`);
container.style.setProperty('--sticky-space-below-header', `${container.clientHeight - headerHeight}px`);
return true;
}
const stickyHeaderObserver = new ResizeObserver(entries => {
for (const entry of entries) {
// The container carries its header along, since it changes size without the header:
const header = entry.target.stickyHeader ?? entry.target;
if (!updateStickyHeaderVariables(header))
stickyHeaderObserver.unobserve(entry.target);
}
});
window.followStickyHeader = function(header) {
const container = header?.parentElement;
if (!container)
return;
container.stickyHeader = header;
stickyHeaderObserver.observe(header);
stickyHeaderObserver.observe(container);
}
window.setDocumentLanguage = function (ietfTag) {
document.documentElement.lang = ietfTag
}
window.formatChatInputMarkdown = function (inputId, formatType) {
let input = document.getElementById(inputId)
if (input && input.tagName !== 'TEXTAREA' && input.tagName !== 'INPUT')
input = input.querySelector('textarea, input')
if (!input)
return ''
input.focus()
const value = input.value ?? ''
const start = input.selectionStart ?? value.length
const end = input.selectionEnd ?? value.length
const hasSelection = end > start
const selectedText = value.substring(start, end)
let insertedText = ''
let selectionStart = start
let selectionEnd = start
switch (formatType) {
case 'bold': {
const text = hasSelection ? selectedText : ''
insertedText = `**${text}**`
selectionStart = start + 2
selectionEnd = selectionStart + text.length
break
}
case 'italic': {
const text = hasSelection ? selectedText : ''
insertedText = `*${text}*`
selectionStart = start + 1
selectionEnd = selectionStart + text.length
break
}
case 'heading': {
if (hasSelection) {
insertedText = selectedText
.split('\n')
.map(line => line.startsWith('# ') ? line : `# ${line}`)
.join('\n')
selectionStart = start
selectionEnd = start + insertedText.length
} else {
const text = ''
insertedText = `# ${text}`
selectionStart = start + 2
selectionEnd = selectionStart + text.length
}
break
}
case 'bullet_list': {
if (hasSelection) {
insertedText = selectedText
.split('\n')
.map(line => line.startsWith('- ') ? line : `- ${line}`)
.join('\n')
selectionStart = start
selectionEnd = start + insertedText.length
} else {
insertedText = '- '
selectionStart = start + 2
selectionEnd = start + insertedText.length
}
break
}
case 'code':
default: {
if (hasSelection) {
if (selectedText.includes('\n')) {
insertedText = `\`\`\`\n${selectedText}\n\`\`\``
selectionStart = start + 4
selectionEnd = selectionStart + selectedText.length
} else {
insertedText = `\`${selectedText}\``
selectionStart = start + 1
selectionEnd = selectionStart + selectedText.length
}
} else {
const text = ''
insertedText = `\`${text}\``
selectionStart = start + 1
selectionEnd = selectionStart + text.length
}
break
}
}
const nextValue = value.slice(0, start) + insertedText + value.slice(end)
input.value = nextValue
input.setSelectionRange(selectionStart, selectionEnd)
input.dispatchEvent(new Event('input', { bubbles: true }))
return nextValue
}
const escapeHandlers = new Map()
window.registerEscapeHandler = function (id, dotNetReference) {
window.unregisterEscapeHandler(id)
const handler = function (event) {
if (event.key !== 'Escape')
return
event.preventDefault()
event.stopPropagation()
dotNetReference.invokeMethodAsync('HandleEscapeKeyAsync').catch(() => {})
}
document.addEventListener('keydown', handler, true)
escapeHandlers.set(id, handler)
}
window.unregisterEscapeHandler = function (id) {
const handler = escapeHandlers.get(id)
if (!handler)
return
document.removeEventListener('keydown', handler, true)
escapeHandlers.delete(id)
}
const localShortcutHandlers = new Map()
function tauriKeyFromKeyboardCode(code) {
if (/^Key[A-Z]$/.test(code))
return code.substring(3)
if (/^Digit[0-9]$/.test(code))
return code.substring(5)
if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code))
return code
const keys = {
Space: 'Space', Enter: 'Enter', Tab: 'Tab', Escape: 'Escape', Backspace: 'Backspace',
Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown',
ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right',
Numpad0: 'Num0', Numpad1: 'Num1', Numpad2: 'Num2', Numpad3: 'Num3', Numpad4: 'Num4',
Numpad5: 'Num5', Numpad6: 'Num6', Numpad7: 'Num7', Numpad8: 'Num8', Numpad9: 'Num9',
NumpadAdd: 'NumAdd', NumpadSubtract: 'NumSubtract', NumpadMultiply: 'NumMultiply',
NumpadDivide: 'NumDivide', NumpadDecimal: 'NumDecimal', NumpadEnter: 'NumEnter',
Minus: 'Minus', Equal: 'Equal', BracketLeft: 'BracketLeft', BracketRight: 'BracketRight',
Backslash: 'Backslash', Semicolon: 'Semicolon', Quote: 'Quote', Backquote: 'Backquote',
Comma: 'Comma', Period: 'Period', Slash: 'Slash'
}
return keys[code] ?? code
}
function parseTauriShortcut(shortcut) {
const expected = { ctrl: false, shift: false, alt: false, meta: false, key: '' }
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
for (const rawPart of shortcut.split('+')) {
const part = rawPart.trim().toLowerCase()
switch (part) {
case 'cmdorcontrol':
case 'commandorcontrol':
expected[isMac ? 'meta' : 'ctrl'] = true
break
case 'ctrl':
case 'control':
expected.ctrl = true
break
case 'cmd':
case 'command':
case 'meta':
case 'super':
expected.meta = true
break
case 'shift':
expected.shift = true
break
case 'alt':
case 'option':
expected.alt = true
break
default:
expected.key = rawPart.trim()
break
}
}
return expected
}
window.localShortcut = {
register: function (id, shortcut, dotNetReference) {
this.unregister(id)
const expected = parseTauriShortcut(shortcut)
if (!expected.key)
return
const handler = function (event) {
if (event.repeat
|| event.ctrlKey !== expected.ctrl
|| event.shiftKey !== expected.shift
|| event.altKey !== expected.alt
|| event.metaKey !== expected.meta
|| tauriKeyFromKeyboardCode(event.code).toLowerCase() !== expected.key.toLowerCase())
return
event.preventDefault()
event.stopPropagation()
dotNetReference.invokeMethodAsync('OnLocalShortcutPressed').catch(() => {})
}
document.addEventListener('keydown', handler, true)
localShortcutHandlers.set(id, handler)
},
unregister: function (id) {
const handler = localShortcutHandlers.get(id)
if (!handler)
return
document.removeEventListener('keydown', handler, true)
localShortcutHandlers.delete(id)
}
}
// What floats above the page without ever being a drop target. Two of these take part in hit testing as
// MudBlazor 9.12 stands: an open .mud-popover -- a closed one already declines pointer events through
// .mud-popover:not(.mud-popover-open) -- and .mud-snackbar, which asks for them explicitly with
// pointer-events: auto even though its container declines them, and snackbars appear constantly in this
// app. Without this list, a drag would be answered by whatever happens to float on screen rather than by
// the page below it. The remaining three are named because they surround those two: .mud-tooltip is the
// content of a popover, while #mud-snackbar-container and .mud-badge-wrapper carry pointer-events: none
// today and therefore never reach a hit test at all. Should a MudBlazor version drop that, they are
// covered here already. Children of all of them have to be skipped as well, which is why the test below
// uses closest rather than matches.
const skippedDropOverlays = '.mud-popover, .mud-tooltip, .mud-snackbar, #mud-snackbar-container, .mud-badge-wrapper'
// The drop zones of the app, addressed by the cursor position of a native drag and drop event.
//
// The arbitration between overlapping zones is left to the browser, and it can be: MudBlazor 9.12 gives
// neither .mud-dialog-container nor .mud-overlay a pointer-events: none. Both fill the viewport while a
// dialog is open, so a point beside the dialog box hits the container, and nothing there is a drop zone. A
// drop, therefore, cannot reach through an open dialog into the page behind it -- the very thing the app
// used to enforce by counting layers in C#. That single CSS property carries this whole design, so it
// belongs on the checklist for every MudBlazor major version.
window.dropZones = {
// Names the drop zone at the given viewport position, or null when there is none.
//
// The stack of elements is walked from the top down rather than asking for the topmost one alone,
// because the topmost one may be an overlay from the list above and skipping it has to reveal what
// lies beneath. The first element which is not skipped ends the walk, whether it belongs to a drop
// zone or not: anything unknown blocks on purpose, so a drop can never slip through something the
// user sees as being in the way. Within that element, closest resolves from the inside out, so a
// specific zone inside a page-wide one wins -- which is exactly the precedence we want.
hitTest: function (x, y) {
for (const element of document.elementsFromPoint(x, y)) {
if (element.closest(skippedDropOverlays))
continue
return element.closest('[data-drop-zone-id]')?.getAttribute('data-drop-zone-id') ?? null
}
return null
},
// Every drop zone currently in the DOM, in document order. This is for diagnostics only: when a drop
// lands nowhere, it answers the question of which zones would have been available at that moment.
list: function () {
return Array.from(document.querySelectorAll('[data-drop-zone-id]'), zone => zone.getAttribute('data-drop-zone-id'))
}
}