mirror of
https://github.com/wavetermdev/waveterm
synced 2026-04-21 14:37:16 +00:00
tsunami / builder updates (jsfuncs, devtools, gpt-5.4, etc) (#3226)
lots of updates for tsunami and builder window: * jsfuncs * devtools windows * devtools proper cleanup (fixes crashes) * scrollbar fixes * lock AI models -- gpt-5.4, builder prompts, etc
This commit is contained in:
parent
3d2c0d1ca8
commit
4969ee19b8
20 changed files with 253 additions and 62 deletions
|
|
@ -4,7 +4,7 @@
|
|||
import { ClientService } from "@/app/store/services";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { randomUUID } from "crypto";
|
||||
import { BrowserWindow } from "electron";
|
||||
import { BrowserWindow, webContents } from "electron";
|
||||
import { globalEvents } from "emain/emain-events";
|
||||
import path from "path";
|
||||
import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform";
|
||||
|
|
@ -87,6 +87,20 @@ export async function createBuilderWindow(appId: string): Promise<BuilderWindowT
|
|||
typedBuilderWindow.builderAppId = appId;
|
||||
typedBuilderWindow.savedInitOpts = initOpts;
|
||||
|
||||
typedBuilderWindow.on("close", () => {
|
||||
const wc = typedBuilderWindow.webContents;
|
||||
if (wc.isDevToolsOpened()) {
|
||||
wc.closeDevTools();
|
||||
}
|
||||
for (const guest of webContents.getAllWebContents()) {
|
||||
if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) {
|
||||
if (guest.isDevToolsOpened()) {
|
||||
guest.closeDevTools();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
typedBuilderWindow.on("focus", () => {
|
||||
focusedBuilderWindow = typedBuilderWindow;
|
||||
console.log("builder window focused", builderId);
|
||||
|
|
|
|||
|
|
@ -490,6 +490,17 @@ export function initIpcHandlers() {
|
|||
console.error("Error deleting builder rtinfo:", e);
|
||||
}
|
||||
}
|
||||
const wc = bw.webContents;
|
||||
if (wc.isDevToolsOpened()) {
|
||||
wc.closeDevTools();
|
||||
}
|
||||
for (const guest of electron.webContents.getAllWebContents()) {
|
||||
if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) {
|
||||
if (guest.isDevToolsOpened()) {
|
||||
guest.closeDevTools();
|
||||
}
|
||||
}
|
||||
}
|
||||
bw.destroy();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ClientService, ObjectService, WindowService, WorkspaceService } from "@
|
|||
import { waveEventSubscribeSingle } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron";
|
||||
import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen, webContents } from "electron";
|
||||
import { globalEvents } from "emain/emain-events";
|
||||
import path from "path";
|
||||
import { debounce } from "throttle-debounce";
|
||||
|
|
@ -299,6 +299,7 @@ export class WaveBrowserWindow extends BaseWindow {
|
|||
if (this.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
this.closeAllDevTools();
|
||||
console.log("win 'close' handler fired", this.waveWindowId);
|
||||
if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) {
|
||||
return;
|
||||
|
|
@ -358,6 +359,24 @@ export class WaveBrowserWindow extends BaseWindow {
|
|||
setTimeout(() => globalEvents.emit("windows-updated"), 50);
|
||||
}
|
||||
|
||||
private closeAllDevTools() {
|
||||
for (const tabView of this.allLoadedTabViews.values()) {
|
||||
if (tabView.webContents?.isDevToolsOpened()) {
|
||||
tabView.webContents.closeDevTools();
|
||||
}
|
||||
}
|
||||
const tabViewIds = new Set(
|
||||
[...this.allLoadedTabViews.values()].map((tv) => tv.webContents?.id).filter((id) => id != null)
|
||||
);
|
||||
for (const wc of webContents.getAllWebContents()) {
|
||||
if (wc.getType() === "webview" && tabViewIds.has(wc.hostWebContents?.id)) {
|
||||
if (wc.isDevToolsOpened()) {
|
||||
wc.closeDevTools();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeAllChildViews() {
|
||||
for (const tabView of this.allLoadedTabViews.values()) {
|
||||
if (!this.isDestroyed()) {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,27 @@ export interface DroppedFile {
|
|||
previewUrl?: string;
|
||||
}
|
||||
|
||||
const BuilderAIModeConfigs: Record<string, AIModeConfigType> = {
|
||||
"waveaibuilder@default": {
|
||||
"display:name": "Builder Default",
|
||||
"display:order": -2,
|
||||
"display:icon": "sparkles",
|
||||
"display:description": "Good mix of speed and accuracy\n(gpt-5.4 with minimal thinking)",
|
||||
"ai:provider": "wave",
|
||||
"ai:switchcompat": ["wavecloud"],
|
||||
"waveai:premium": true,
|
||||
},
|
||||
"waveaibuilder@deep": {
|
||||
"display:name": "Builder Deep",
|
||||
"display:order": -1,
|
||||
"display:icon": "lightbulb",
|
||||
"display:description": "Slower but most capable\n(gpt-5.4 with full reasoning)",
|
||||
"ai:provider": "wave",
|
||||
"ai:switchcompat": ["wavecloud"],
|
||||
"waveai:premium": true,
|
||||
},
|
||||
};
|
||||
|
||||
export class WaveAIModel {
|
||||
private static instance: WaveAIModel | null = null;
|
||||
inputRef: React.RefObject<AIPanelInputRef> | null = null;
|
||||
|
|
@ -80,7 +101,11 @@ export class WaveAIModel {
|
|||
this.orefContext = orefContext;
|
||||
this.inBuilder = inBuilder;
|
||||
this.chatId = jotai.atom(null) as jotai.PrimitiveAtom<string>;
|
||||
this.aiModeConfigs = atoms.waveaiModeConfigAtom;
|
||||
if (inBuilder) {
|
||||
this.aiModeConfigs = jotai.atom(BuilderAIModeConfigs) as jotai.Atom<Record<string, AIModeConfigType>>;
|
||||
} else {
|
||||
this.aiModeConfigs = atoms.waveaiModeConfigAtom;
|
||||
}
|
||||
|
||||
this.hasPremiumAtom = jotai.atom((get) => {
|
||||
const rateLimitInfo = get(atoms.waveAIRateLimitInfoAtom);
|
||||
|
|
@ -118,7 +143,7 @@ export class WaveAIModel {
|
|||
this.defaultModeAtom = jotai.atom((get) => {
|
||||
const telemetryEnabled = get(getSettingsKeyAtom("telemetry:enabled")) ?? false;
|
||||
if (this.inBuilder) {
|
||||
return telemetryEnabled ? "waveai@balanced" : "invalid";
|
||||
return telemetryEnabled ? "waveaibuilder@default" : "invalid";
|
||||
}
|
||||
const aiModeConfigs = get(this.aiModeConfigs);
|
||||
if (!telemetryEnabled) {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ import { openLink } from "@/store/global";
|
|||
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
|
||||
import { fireAndForget, useAtomValueSafe } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import { WebviewTag } from "electron";
|
||||
import type { WebviewTag } from "electron";
|
||||
import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, createRef, memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import "./webview.scss";
|
||||
import type { WebViewEnv } from "./webviewenv";
|
||||
|
||||
|
|
@ -951,6 +951,15 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
|
|||
}, 100);
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
const webview = model.webviewRef.current;
|
||||
if (webview?.isDevToolsOpened()) {
|
||||
webview.closeDevTools();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
globalStore.set(model.domReady, false);
|
||||
|
|
|
|||
|
|
@ -257,6 +257,10 @@ const BuilderAppPanel = memo(() => {
|
|||
model.switchBuilderApp();
|
||||
}, [model]);
|
||||
|
||||
const handleOpenDevToolsClick = useCallback(() => {
|
||||
model.openPreviewDevTools();
|
||||
}, [model]);
|
||||
|
||||
const handleKebabClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const menu: ContextMenuItem[] = [
|
||||
|
|
@ -267,6 +271,13 @@ const BuilderAppPanel = memo(() => {
|
|||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Open DevTools",
|
||||
click: handleOpenDevToolsClick,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Switch App",
|
||||
click: handleSwitchAppClick,
|
||||
|
|
@ -274,7 +285,7 @@ const BuilderAppPanel = memo(() => {
|
|||
];
|
||||
ContextMenuModel.getInstance().showContextMenu(menu, e);
|
||||
},
|
||||
[handleSwitchAppClick, handlePublishClick]
|
||||
[handleSwitchAppClick, handlePublishClick, handleOpenDevToolsClick]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { RpcApi } from "@/app/store/wshclientapi";
|
|||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { atoms, getApi, WOS } from "@/store/global";
|
||||
import { base64ToString, stringToBase64 } from "@/util/util";
|
||||
import type { WebviewTag } from "electron";
|
||||
import { atom, type Atom, type PrimitiveAtom } from "jotai";
|
||||
import type * as MonacoTypes from "monaco-editor";
|
||||
import { debounce } from "throttle-debounce";
|
||||
|
|
@ -35,6 +36,7 @@ export class BuilderAppPanelModel {
|
|||
saveNeededAtom!: Atom<boolean>;
|
||||
focusElemRef: { current: HTMLInputElement | null } = { current: null };
|
||||
monacoEditorRef: { current: MonacoTypes.editor.IStandaloneCodeEditor | null } = { current: null };
|
||||
webviewRef: { current: WebviewTag | null } = { current: null };
|
||||
statusUnsubFn: (() => void) | null = null;
|
||||
appGoUpdateUnsubFn: (() => void) | null = null;
|
||||
debouncedRestart: (() => void) & { cancel: () => void };
|
||||
|
|
@ -314,6 +316,15 @@ export class BuilderAppPanelModel {
|
|||
this.monacoEditorRef.current = ref;
|
||||
}
|
||||
|
||||
openPreviewDevTools() {
|
||||
if (!this.webviewRef.current) return;
|
||||
if (this.webviewRef.current.isDevToolsOpened()) {
|
||||
this.webviewRef.current.closeDevTools();
|
||||
} else {
|
||||
this.webviewRef.current.openDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.statusUnsubFn) {
|
||||
this.statusUnsubFn();
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => {
|
|||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold text-error">Secrets Required</h2>
|
||||
<p className="text-base text-secondary leading-relaxed">
|
||||
This app requires secrets that must be configured. Please use the Secrets tab to set and bind
|
||||
the required secrets for your app to run.
|
||||
This app requires secrets that must be configured. Please use the Secrets tab to set and
|
||||
bind the required secrets for your app to run.
|
||||
</p>
|
||||
<div className="text-left bg-panel border border-error/30 rounded-lg p-4 max-h-96 overflow-auto mt-2">
|
||||
<pre className="text-sm text-secondary whitespace-pre-wrap font-mono">{displayMsg}</pre>
|
||||
|
|
@ -178,47 +178,48 @@ const BuilderPreviewTab = memo(() => {
|
|||
const originalContent = useAtomValue(model.originalContentAtom);
|
||||
const builderStatus = useAtomValue(model.builderStatusAtom);
|
||||
const builderId = useAtomValue(atoms.builderId);
|
||||
|
||||
const fileExists = originalContent.length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (builderStatus?.status === "error") {
|
||||
return <ErrorStateView errorMsg={builderStatus?.errormsg || ""} />;
|
||||
}
|
||||
|
||||
if (!fileExists) {
|
||||
return <EmptyStateView />;
|
||||
}
|
||||
const [lastKnownUrl, setLastKnownUrl] = useState<string>(null);
|
||||
|
||||
const status = builderStatus?.status || "init";
|
||||
const isWebViewActive = status === "running" && builderStatus?.port && builderStatus.port !== 0;
|
||||
|
||||
if (status === "init") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "building") {
|
||||
return <BuildingStateView />;
|
||||
}
|
||||
|
||||
if (status === "stopped") {
|
||||
return <StoppedStateView onStart={() => model.startBuilder()} />;
|
||||
}
|
||||
|
||||
const shouldShowWebView = status === "running" && builderStatus?.port && builderStatus.port !== 0;
|
||||
|
||||
if (shouldShowWebView) {
|
||||
if (isWebViewActive) {
|
||||
const previewUrl = `http://localhost:${builderStatus.port}/?clientid=wave:${builderId}`;
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<webview src={previewUrl} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
if (previewUrl !== lastKnownUrl) {
|
||||
setLastKnownUrl(previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
let overlay = null;
|
||||
if (!isLoading && !isWebViewActive) {
|
||||
if (builderStatus?.status === "error") {
|
||||
overlay = <ErrorStateView errorMsg={builderStatus?.errormsg || ""} />;
|
||||
} else if (!fileExists || status === "init") {
|
||||
overlay = <EmptyStateView />;
|
||||
} else if (status === "building") {
|
||||
overlay = <BuildingStateView />;
|
||||
} else if (status === "stopped") {
|
||||
overlay = <StoppedStateView onStart={() => model.startBuilder()} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
{lastKnownUrl && (
|
||||
<webview
|
||||
ref={model.webviewRef}
|
||||
src={lastKnownUrl}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
visibility: isWebViewActive ? "visible" : "hidden",
|
||||
pointerEvents: isWebViewActive ? "auto" : "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{overlay && <div className="absolute inset-0">{overlay}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BuilderPreviewTab.displayName = "BuilderPreviewTab";
|
||||
|
|
|
|||
|
|
@ -163,9 +163,11 @@ const (
|
|||
)
|
||||
|
||||
const (
|
||||
AIModeQuick = "waveai@quick"
|
||||
AIModeBalanced = "waveai@balanced"
|
||||
AIModeDeep = "waveai@deep"
|
||||
AIModeQuick = "waveai@quick"
|
||||
AIModeBalanced = "waveai@balanced"
|
||||
AIModeDeep = "waveai@deep"
|
||||
AIModeBuilderDefault = "waveaibuilder@default"
|
||||
AIModeBuilderDeep = "waveaibuilder@deep"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -247,7 +247,44 @@ func isValidAzureResourceName(name string) bool {
|
|||
return AzureResourceNameRegex.MatchString(name)
|
||||
}
|
||||
|
||||
var builderModeConfigs = map[string]wconfig.AIModeConfigType{
|
||||
uctypes.AIModeBuilderDefault: {
|
||||
DisplayName: "Builder Default",
|
||||
DisplayOrder: -2,
|
||||
DisplayIcon: "sparkles",
|
||||
DisplayDescription: "Good mix of speed and accuracy\n(gpt-5.4 with minimal thinking)",
|
||||
Provider: uctypes.AIProvider_Wave,
|
||||
APIType: uctypes.APIType_OpenAIResponses,
|
||||
Model: "gpt-5.4",
|
||||
ThinkingLevel: uctypes.ThinkingLevelLow,
|
||||
Verbosity: uctypes.VerbosityLevelLow,
|
||||
Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs},
|
||||
WaveAIPremium: true,
|
||||
SwitchCompat: []string{"wavecloud"},
|
||||
},
|
||||
uctypes.AIModeBuilderDeep: {
|
||||
DisplayName: "Builder Deep",
|
||||
DisplayOrder: -1,
|
||||
DisplayIcon: "lightbulb",
|
||||
DisplayDescription: "Slower but most capable\n(gpt-5.4 with full reasoning)",
|
||||
Provider: uctypes.AIProvider_Wave,
|
||||
APIType: uctypes.APIType_OpenAIResponses,
|
||||
Model: "gpt-5.4",
|
||||
ThinkingLevel: uctypes.ThinkingLevelMedium,
|
||||
Verbosity: uctypes.VerbosityLevelLow,
|
||||
Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs},
|
||||
WaveAIPremium: true,
|
||||
SwitchCompat: []string{"wavecloud"},
|
||||
},
|
||||
}
|
||||
|
||||
func getAIModeConfig(aiMode string) (*wconfig.AIModeConfigType, error) {
|
||||
if config, ok := builderModeConfigs[aiMode]; ok {
|
||||
resolved := config
|
||||
applyProviderDefaults(&resolved)
|
||||
return &resolved, nil
|
||||
}
|
||||
|
||||
fullConfig := wconfig.GetWatcher().GetFullConfig()
|
||||
config, ok := fullConfig.WaveAIModes[aiMode]
|
||||
if !ok {
|
||||
|
|
@ -271,13 +308,13 @@ func handleConfigUpdate(fullConfig wconfig.FullConfigType) {
|
|||
|
||||
func ComputeResolvedAIModeConfigs(fullConfig wconfig.FullConfigType) map[string]wconfig.AIModeConfigType {
|
||||
resolvedConfigs := make(map[string]wconfig.AIModeConfigType)
|
||||
|
||||
|
||||
for modeName, modeConfig := range fullConfig.WaveAIModes {
|
||||
resolved := modeConfig
|
||||
applyProviderDefaults(&resolved)
|
||||
resolvedConfigs[modeName] = resolved
|
||||
}
|
||||
|
||||
|
||||
return resolvedConfigs
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +322,7 @@ func broadcastAIModeConfigs(configs map[string]wconfig.AIModeConfigType) {
|
|||
update := wconfig.AIModeConfigUpdate{
|
||||
Configs: configs,
|
||||
}
|
||||
|
||||
|
||||
wps.Broker.Publish(wps.WaveEvent{
|
||||
Event: wps.Event_AIModeConfig,
|
||||
Data: update,
|
||||
|
|
|
|||
|
|
@ -670,8 +670,8 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Get WaveAI settings
|
||||
premium := shouldUsePremium()
|
||||
builderMode := req.BuilderId != ""
|
||||
premium := shouldUsePremium() || builderMode
|
||||
if req.AIMode == "" {
|
||||
http.Error(w, "aimode is required in request body", http.StatusBadRequest)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"downlevelIteration": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/app/*": ["frontend/app/*"],
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const globalModel = new TsunamiModel();
|
|||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="h-full bg-background text-foreground overflow-auto">
|
||||
<VDomView model={globalModel} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,10 @@
|
|||
}
|
||||
|
||||
/* Disable overscroll behavior */
|
||||
html, body {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
color-scheme: dark;
|
||||
background: var(--color-background);
|
||||
overscroll-behavior: none;
|
||||
overscroll-behavior-x: none;
|
||||
overscroll-behavior-y: none;
|
||||
|
|
|
|||
1
tsunami/frontend/src/types/custom.d.ts
vendored
1
tsunami/frontend/src/types/custom.d.ts
vendored
|
|
@ -12,4 +12,5 @@ type KeyPressDecl = {
|
|||
};
|
||||
key: string;
|
||||
keyType: string;
|
||||
nomatch?: boolean;
|
||||
};
|
||||
|
|
|
|||
2
tsunami/frontend/src/types/vdom.d.ts
vendored
2
tsunami/frontend/src/types/vdom.d.ts
vendored
|
|
@ -82,8 +82,10 @@ type VDomFunc = {
|
|||
type: "func";
|
||||
stoppropagation?: boolean;
|
||||
preventdefault?: boolean;
|
||||
preventbackend?: boolean;
|
||||
globalevent?: string;
|
||||
keys?: string[];
|
||||
jscode?: string;
|
||||
};
|
||||
|
||||
// vdom.VDomMessage
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ function parseKey(key: string): { key: string; type: string } {
|
|||
function parseKeyDescription(keyDescription: string): KeyPressDecl {
|
||||
let rtn = { key: "", mods: {} } as KeyPressDecl;
|
||||
let keys = keyDescription.replace(/[()]/g, "").split(":");
|
||||
for (let key of keys) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
let key = keys[i];
|
||||
let isLastToken = i === keys.length - 1;
|
||||
if (key == "Cmd") {
|
||||
if (PLATFORM == PlatformMacOS) {
|
||||
rtn.mods.Meta = true;
|
||||
|
|
@ -106,6 +108,10 @@ function parseKeyDescription(keyDescription: string): KeyPressDecl {
|
|||
}
|
||||
rtn.mods.Meta = true;
|
||||
} else {
|
||||
if (!isLastToken) {
|
||||
rtn.nomatch = true;
|
||||
return rtn;
|
||||
}
|
||||
let { key: parsedKey, type: keyType } = parseKey(key);
|
||||
rtn.key = parsedKey;
|
||||
rtn.keyType = keyType;
|
||||
|
|
@ -194,6 +200,9 @@ function isInputEvent(event: VDomKeyboardEvent): boolean {
|
|||
|
||||
function checkKeyPressed(event: VDomKeyboardEvent, keyDescription: string): boolean {
|
||||
let keyPress = parseKeyDescription(keyDescription);
|
||||
if (keyPress.nomatch) {
|
||||
return false;
|
||||
}
|
||||
if (notMod(keyPress.mods.Option, event.option)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -236,6 +245,9 @@ function checkKeyPressed(event: VDomKeyboardEvent, keyDescription: string): bool
|
|||
}
|
||||
|
||||
function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): VDomKeyboardEvent {
|
||||
if (event == null || typeof event.key !== "string") {
|
||||
return { type: "unknown" } as VDomKeyboardEvent;
|
||||
}
|
||||
let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent;
|
||||
rtn.control = event.ctrlKey;
|
||||
rtn.shift = event.shiftKey;
|
||||
|
|
|
|||
|
|
@ -170,15 +170,21 @@ const SvgUrlIdAttributes = {
|
|||
"text-decoration": true,
|
||||
};
|
||||
|
||||
function convertVDomFunc(model: TsunamiModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void {
|
||||
return (e: any) => {
|
||||
function convertVDomFunc(
|
||||
model: TsunamiModel,
|
||||
fnDecl: VDomFunc,
|
||||
compId: string,
|
||||
propName: string
|
||||
): (...args: any[]) => any {
|
||||
return (...args: any[]) => {
|
||||
const e = args[0];
|
||||
if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["keys"]) {
|
||||
dlog("key event", fnDecl, e);
|
||||
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
|
||||
for (let keyDesc of fnDecl["keys"] || []) {
|
||||
if (checkKeyPressed(waveEvent, keyDesc)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e?.preventDefault?.();
|
||||
e?.stopPropagation?.();
|
||||
model.callVDomFunc(fnDecl, e, compId, propName);
|
||||
return;
|
||||
}
|
||||
|
|
@ -186,12 +192,24 @@ function convertVDomFunc(model: TsunamiModel, fnDecl: VDomFunc, compId: string,
|
|||
return;
|
||||
}
|
||||
if (fnDecl.preventdefault) {
|
||||
e.preventDefault();
|
||||
e?.preventDefault?.();
|
||||
}
|
||||
if (fnDecl.stoppropagation) {
|
||||
e.stopPropagation();
|
||||
e?.stopPropagation?.();
|
||||
}
|
||||
model.callVDomFunc(fnDecl, e, compId, propName);
|
||||
let retVal: any;
|
||||
if (fnDecl.jscode) {
|
||||
try {
|
||||
const fn = eval(fnDecl.jscode);
|
||||
if (typeof fn === "function") retVal = fn(...args);
|
||||
} catch (err) {
|
||||
console.error("vdom jscode error:", err);
|
||||
}
|
||||
}
|
||||
if (!fnDecl.preventbackend) {
|
||||
model.callVDomFunc(fnDecl, e, compId, propName);
|
||||
}
|
||||
return retVal;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +272,7 @@ function convertChildren(elem: VDomElem, model: TsunamiModel): React.ReactNode[]
|
|||
if (elem.children == null || elem.children.length == 0) {
|
||||
return null;
|
||||
}
|
||||
let childrenComps: React.ReactNode[] = [];
|
||||
const childrenComps: React.ReactNode[] = [];
|
||||
for (let child of elem.children) {
|
||||
if (child == null) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -99,6 +99,20 @@ func H(tag string, props map[string]any, children ...any) *VDomElem {
|
|||
return rtn
|
||||
}
|
||||
|
||||
// JSFunc creates a VDomFunc that executes client-side JS only, with no backend call.
|
||||
// jsCode must be a JS function expression whose signature matches the natural arguments of the event handler
|
||||
// (e.g. (e) => { ... } for DOM events, or whatever args the underlying handler receives).
|
||||
func JSFunc(jsCode string) *VDomFunc {
|
||||
return &VDomFunc{Type: ObjectType_Func, JsCode: jsCode, PreventBackend: true}
|
||||
}
|
||||
|
||||
// CombinedFunc creates a VDomFunc that executes client-side JS first, then fires to the backend.
|
||||
// jsCode must be a JS function expression whose signature matches the natural arguments of the event handler
|
||||
// (e.g. (e) => { ... } for DOM events, or whatever args the underlying handler receives).
|
||||
func CombinedFunc(jsCode string, fn any) *VDomFunc {
|
||||
return &VDomFunc{Type: ObjectType_Func, JsCode: jsCode, Fn: fn}
|
||||
}
|
||||
|
||||
// If returns the provided part if the condition is true, otherwise returns nil.
|
||||
// This is useful for conditional rendering in VDOM children lists, props, and style attributes.
|
||||
func If(cond bool, part any) any {
|
||||
|
|
|
|||
|
|
@ -32,8 +32,10 @@ type VDomFunc struct {
|
|||
Type string `json:"type" tstype:"\"func\""`
|
||||
StopPropagation bool `json:"stoppropagation,omitempty"` // set to call e.stopPropagation() on the client side
|
||||
PreventDefault bool `json:"preventdefault,omitempty"` // set to call e.preventDefault() on the client side
|
||||
PreventBackend bool `json:"preventbackend,omitempty"` // set to skip firing the event to the backend
|
||||
GlobalEvent string `json:"globalevent,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture"
|
||||
JsCode string `json:"jscode,omitempty"` // client-side JS function expression: (e, elem) => { ... }
|
||||
}
|
||||
|
||||
// used in props
|
||||
|
|
|
|||
Loading…
Reference in a new issue