diff --git a/playwright-tests/data/actionBlockElements.json b/playwright-tests/data/actionBlockElements.json index 30736a068..6dd18d1c2 100644 --- a/playwright-tests/data/actionBlockElements.json +++ b/playwright-tests/data/actionBlockElements.json @@ -65,7 +65,7 @@ "Encoder Push & Rotate": ["push rotate", "just rotate", "end"] }, "code": { - "Code Block": ["input", "Edit Code"], + "Code Block": ["input", "Open Editor"], "Comment Block": ["input"], "Element Name": ["input"] }, diff --git a/playwright-tests/data/actionBlockLocators.js b/playwright-tests/data/actionBlockLocators.js index 9014f9ecd..977a94ebe 100644 --- a/playwright-tests/data/actionBlockLocators.js +++ b/playwright-tests/data/actionBlockLocators.js @@ -296,8 +296,8 @@ export const blocks = (page) => ({ "Code Block": { block: page.locator("#action-menu").getByText("Code Block"), elements: { - input: page.locator("pre"), - "Edit Code": page.getByRole("button", { name: "Edit Code" }), + input: page.locator("code-block .view-line").first(), + "Open Editor": page.getByRole("button", { name: "Open Editor" }), }, }, "Comment Block": { diff --git a/playwright-tests/pages/configPage.js b/playwright-tests/pages/configPage.js index 3b0291240..8e0e18db9 100644 --- a/playwright-tests/pages/configPage.js +++ b/playwright-tests/pages/configPage.js @@ -76,9 +76,18 @@ export class ConfigPage { .filter({ hasText: "End" }) .locator("action-placeholder div") .first(); - this.commitCodeButton = page.getByRole("button", { name: "Commit" }); - this.closeCodeButton = page.getByRole("button", { name: "Close" }); - this.codeblockInput = page.locator(".view-line").first(); + // The code block now has an inline editor with its own Commit button, so + // scope these to the full-screen editor modal (#monaco-container and its + // parent, which holds the modal's Commit/Close buttons). + this.commitCodeButton = page + .locator("#monaco-container") + .locator("xpath=..") + .getByRole("button", { name: "Commit" }); + this.closeCodeButton = page + .locator("#monaco-container") + .locator("xpath=..") + .getByRole("button", { name: "Close" }); + this.codeblockInput = page.locator("#monaco-container .view-line").first(); this.codeBlockCharacterLimitMessage = page.getByText( "Config limit reached.", ); @@ -100,11 +109,13 @@ export class ConfigPage { } async openAndAddActionBlock(category, blockName) { + await this.resetTransientUi(); await this.addActionBlockButton.click(); await this.blocks[category][blockName]["block"].click(); } async openActionBlockList() { + await this.resetTransientUi(); await this.addActionBlockButton.click(); } @@ -178,7 +189,10 @@ export class ConfigPage { // Element and Action Operations async selectAllActions() { - await this.configFocus.click(); + // Focus the config panel itself rather than clicking into the area (which + // can land in an inline code editor that captures Ctrl+A as select-text), + // so the select-all-actions shortcut actually fires. + await this.page.locator(".configpanel").first().focus(); await this.keyboardActions.selectAll(); } @@ -196,6 +210,26 @@ export class ConfigPage { async clearElement() { await this.elementButtons.clear.click(); + // Clearing raises a transient progress toast ("Clearing element + // configuration..." → "...was reset to default!") that overlays the UI and + // can intercept the next click — including in a following test, since the + // page is shared across the spec. Reset it so it can't bleed across. + await this.resetTransientUi(); + } + + // Reset transient, page-level UI that bleeds across tests on the shared page: + // notification toasts (which otherwise linger ~5s) via the web build's test + // hook, and any leftover-open action menu / popover (Escape). Both can + // overlay and intercept a later click. + async resetTransientUi() { + await this.page + .evaluate(() => window.__gridTest?.resetLogStream?.()) + .catch(() => {}); + // Close any leftover-open action menu. Its search input is auto-focused and + // swallows Escape, so blur first to let the key reach the menu's + // document-level Escape handler. + await this.page.evaluate(() => document.activeElement?.blur()); + await this.page.keyboard.press("Escape"); } async copyAction() { @@ -244,8 +278,9 @@ export class ConfigPage { async addAndEditCodeBlock(code) { await this.addCodeBlock(); - await this.blocks["code"]["Code Block"]["elements"]["Edit Code"].click(); - await this.page.getByText("Synced with Grid!").click(); + await this.blocks["code"]["Code Block"]["elements"]["Open Editor"].click(); + // Both the inline block and the modal show this status, so target one. + await this.page.getByText("Synced with Grid!").first().click(); await this.codeblockInput.click({ clickCount: 1 }); await this.keyboardActions.selectAll(); diff --git a/playwright-tests/pages/modulePage.js b/playwright-tests/pages/modulePage.js index 5e4b4fe4f..dde7ed8ce 100644 --- a/playwright-tests/pages/modulePage.js +++ b/playwright-tests/pages/modulePage.js @@ -93,8 +93,14 @@ export class ModulePage { name: "Actions have been copied", }); - this.characterLimitPasteToast = page.getByText("Modifications can not"); - this.characterLimitAddToast = page.getByText("Modifications can not"); + // The same toast can stack (one per affected module/event), so target one + // to avoid strict-mode violations. + this.characterLimitPasteToast = page + .getByText("Modifications can not") + .first(); + this.characterLimitAddToast = page + .getByText("Modifications can not") + .first(); this.storeButton = page.getByRole("button", { name: "Store" }); this.clearButton = page.getByRole("button", { name: "Clear" }); this.confirmClearButton = page.getByRole("button", { diff --git a/playwright-tests/tests/actionBlockExistence.spec.js b/playwright-tests/tests/actionBlockExistence.spec.js index e89e756e9..8ed1c2759 100644 --- a/playwright-tests/tests/actionBlockExistence.spec.js +++ b/playwright-tests/tests/actionBlockExistence.spec.js @@ -26,6 +26,9 @@ async function setupModule(moduleName) { await connectModulePage.addModule(moduleName); } async function changeModuleIfNeeded(category) { + // A prior test can leave the action menu open / a toast up on the shared + // page, which would intercept the "Remove Module" click below. + await configPage.resetTransientUi(); if (category === "specialButton") { await modulePage.removeModule(); await setupModule("BU16"); diff --git a/playwright-tests/tests/blocksTests.spec.js b/playwright-tests/tests/blocksTests.spec.js index 698776ade..bd1b0b321 100644 --- a/playwright-tests/tests/blocksTests.spec.js +++ b/playwright-tests/tests/blocksTests.spec.js @@ -38,7 +38,7 @@ test.describe("Issues", () => { await configPage.selectAllActions(); await page .getByTestId("action-block") - .filter({ hasText: 'Code preview: print("hello")' }) + .filter({ hasText: 'print("hello")' }) .getByTestId(/^action-checkbox/) .click(); //uncheck codeblock await configPage.removeAction(); diff --git a/src/renderer/App.svelte b/src/renderer/App.svelte index a2ffd1238..51bd2a8d0 100644 --- a/src/renderer/App.svelte +++ b/src/renderer/App.svelte @@ -32,6 +32,7 @@ import { debug_lowlevel_store } from "./main/panels/WebsocketMonitor/WebsocketMonitor.store"; import { logger } from "./runtime/runtime.store"; + import { logStreamStore } from "./main/user-interface/cursor-log/LogStream.store"; import MiddlePanelContainer from "./main/MiddlePanelContainer.svelte"; import PanelToggleButton from "./main/PanelToggleButton.svelte"; @@ -285,6 +286,14 @@ //Disable Context Menu onMount(async () => { document.addEventListener("contextmenu", preventContextMenuEvent); + // Test-only seam: let the (web build) e2e suite reset transient UI state — + // notification toasts that otherwise linger ~5s and bleed across tests. + if (import.meta.env.VITE_BUILD_TARGET === "web") { + (window as any).__gridTest = { + ...(window as any).__gridTest, + resetLogStream: () => logStreamStore.reset(), + }; + } loaded = true; window.electron.appLoaded(); }); diff --git a/src/renderer/config-blocks/CodeBlock.svelte b/src/renderer/config-blocks/CodeBlock.svelte index 47159a884..731d0a6c2 100644 --- a/src/renderer/config-blocks/CodeBlock.svelte +++ b/src/renderer/config-blocks/CodeBlock.svelte @@ -38,125 +38,190 @@ - -
-
Code preview:
- -
-
+
+  
+
+ +
+
+ + codeEditor.reset()} + disabled={!inlineDirty || editingInModal} + text={"Discard"} + /> +
+ codeEditor.commit()} + disabled={!commitEnabled || editingInModal} + text={"Commit"} + style={"accept"} + /> +
+
-
- + -
- - navigator.clipboard.writeText( - GridScript.expandScript($action.script), - )} - text={"Copy Code"} - > - -
-
-
-
-
Powered by Lua
- -
+ {#if errorMessage} +
+ {errorMessage} +
+ {/if} +
-
- {@html lualogo} + {#if editingInModal} +
+ + Currently open in editor window! +
-
+ {/if} diff --git a/src/renderer/main/modals/Monaco.svelte b/src/renderer/main/modals/Monaco.svelte index 95a70991f..8d89481b0 100644 --- a/src/renderer/main/modals/Monaco.svelte +++ b/src/renderer/main/modals/Monaco.svelte @@ -14,43 +14,70 @@ MoltenPushButton, SvgIcon, } from "@intechstudio/grid-uikit"; - import { onDestroy, tick } from "svelte"; - import { NumberToEventType, GridScript } from "@intechstudio/grid-protocol"; + import { onDestroy, onMount } from "svelte"; + import { NumberToEventType } from "@intechstudio/grid-protocol"; import { Modal, modalManager } from "./modal.store"; + import { activeMonacoSession } from "./monaco-session.store"; + import { get } from "svelte/store"; import MoltenModal from "./MoltenModal.svelte"; - import { onMount } from "svelte"; - import { appSettings } from "../../runtime/app-helper.store"; import { clickOutside } from "../_actions/click-outside.action"; - import { updateAction } from "../../runtime/operations"; import { MonacoEditor } from "../../lib/monaco"; + import CodeEditor from "../user-interface/CodeEditor.svelte"; + import CommitStatus from "../user-interface/CommitStatus.svelte"; + import CharacterCount from "../user-interface/CharacterCount.svelte"; + import SendFeedback from "../user-interface/SendFeedback.svelte"; import DebugTextList from "../panels/DebugMonitor/DebugTextList.svelte"; import ConfirmModal from "./ConfirmModal.svelte"; + const lualogo_foreground = "#808080"; + const lualogo_background = "#212a2c"; + + const lualogo = ` + + + + + + + + + + + +`; + export let data: Modal.Instance; export let monaco_action: GridAction; + // Optional uncommitted content carried over from the inline editor. + export let initial_value: string | undefined = undefined; - let event: GridEvent; - let element: GridElement; - let monaco_block; - let editor; + let codeEditor: CodeEditor; + let commitButton: HTMLElement; + let editor: MonacoEditor.CustomCodeEditor | undefined; let commitEnabled = false; let errorMessage = ""; - let commited = { script: "", name: "" }; - let scriptLength = undefined; + let scriptLength: number | undefined = undefined; let pathSnippets = []; let name; let isEditName = false; let nameInput; let clickedOutside = false; - let monaco_disposables = []; - - class LengthError extends String {} + const sessionToken = {}; - $: handleFontSizechange($appSettings.persistent.fontSize); - - function handleFontSizechange(fontSize) { - editor?.updateOptions({ fontSize }); - } + // Element type drives autocomplete scope; available synchronously for the + // CodeEditor child's initial mount. + const element = (monaco_action.parent as GridEvent)?.parent as GridElement; $: handleActionChange($monaco_action); @@ -87,91 +114,28 @@ ]; } - onMount(async () => { - await tick(); - event = monaco_action.parent as GridEvent; - element = event.parent as GridElement; - commited.name = monaco_action.name; - commited.script = monaco_action.script; - scriptLength = event.toLua().length; - - editor = MonacoEditor.create(monaco_block, { - value: GridScript.expandScript(monaco_action.script), - language: "intech_lua", - theme: $appSettings.persistent.lightMode - ? MonacoEditor.Theme.LIGHT - : MonacoEditor.Theme.DARK, - fontSize: $appSettings.persistent.fontSize, - restrictScope: $element.type, - folding: false, - renderLineHighlight: "none", - contextmenu: false, - scrollBeyondLastLine: false, - automaticLayout: true, - wordWrap: "on", - suggest: { - showIcons: false, - showWords: true, - }, - minimap: { enabled: false }, + onMount(() => { + activeMonacoSession.set({ + token: sessionToken, + action: monaco_action, + dirty, + close: () => data.close(), }); - - editor.onDidChangeModelContent(handleContentChange); }); - $: if (editor) { - handleLightModeChange($appSettings.persistent.lightMode); - } + // "Needs committing": unsaved changes or an unresolved error. + $: dirty = commitEnabled || errorMessage !== ""; - function handleLightModeChange(value: boolean) { - MonacoEditor.setTheme( - value ? MonacoEditor.Theme.LIGHT : MonacoEditor.Theme.DARK, - ); - } - - function handleContentChange() { - try { - const compressed = GridScript.compressScript(editor.getValue()); - $monaco_action.script = compressed; - $monaco_action.name = - name !== $monaco_action?.information.displayName ? name : undefined; - scriptLength = ($monaco_action.parent as GridEvent).toLua().length; - if (scriptLength >= Grid.Protocol.maxScriptLength) { - throw new LengthError("Config limit reached."); - } - errorMessage = ""; - commitEnabled = - $monaco_action.script !== commited.script || - commited.name !== $monaco_action.name; - } catch (e) { - if (!(e instanceof LengthError)) { - scriptLength = undefined; - } - commitEnabled = false; - errorMessage = String(e).replace(/^error parsing: ?\n?/i, ""); - } - $monaco_action.name = commited.name; - $monaco_action.script = commited.script; - } - - async function handleCommitClicked() { - updateAction( - monaco_action, - new ActionData( - monaco_action.short, - GridScript.compressScript(editor.getValue()), - name !== $monaco_action?.information.displayName ? name : undefined, - ), - true, - ).then(() => { - commited.name = $monaco_action.name; - commited.script = $monaco_action.script; - commitEnabled = false; - }); - } + // Keep the shared session in sync so other code blocks can react to whether + // this editor currently needs committing (issue #1390, part 1). + $: activeMonacoSession.update((s) => + s?.token === sessionToken ? { ...s, dirty } : s, + ); onDestroy(() => { - monaco_disposables.forEach((d) => d.dispose()); + if (get(activeMonacoSession)?.token === sessionToken) { + activeMonacoSession.set(null); + } }); function handleClose() { @@ -218,15 +182,16 @@ clickedOutside = true; } - function handleNameChange(value) { - if (value === monaco_action?.information.displayName) return; - if (value !== monaco_action?.name) { - handleContentChange(); + // Ctrl/Cmd+S commits the editor by triggering the Commit button, which + // no-ops on its own when disabled. Scoped to this modal's content subtree. + function handleKeydown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + e.stopPropagation(); + commitButton?.querySelector("button")?.click(); } } - $: handleNameChange(name); - function handleWindowKeydown(e: KeyboardEvent) { if (modalManager.getTop() !== data) { return; @@ -262,6 +227,7 @@ slot="content" class="h-full w-full relative flex flex-col gap-2 items-start text-foreground" use:watchResize={handleResize} + on:keydown={handleKeydown} >
@@ -286,38 +252,22 @@
- {`Character Count: ${typeof scriptLength === "undefined" ? "?" : scriptLength}/${ - Grid.Protocol.maxScriptLength - 1 - } (max)`} +
- {#if isDeleted($monaco_action)} -
Deleted Action
- {:else} -
- {commitEnabled ? "Unsaved changes!" : "Synced with Grid!"} -
- {/if} - - - -
+ -
- {errorMessage} +
+ codeEditor.commit()} + disabled={!commitEnabled || isDeleted($monaco_action)} + text="Commit" + style="accept" + /> +
+
@@ -335,12 +285,37 @@ {/if} {/each}
-
+ +
+ +
+ {errorMessage}
+ +
+ +
+
Powered by Lua
+
+ {@html lualogo} +
+
diff --git a/src/renderer/main/modals/monaco-session.store.ts b/src/renderer/main/modals/monaco-session.store.ts new file mode 100644 index 000000000..a3b36a6af --- /dev/null +++ b/src/renderer/main/modals/monaco-session.store.ts @@ -0,0 +1,20 @@ +import { writable } from "svelte/store"; +import type { GridAction } from "../../runtime/runtime"; + +// Tracks the currently-open Monaco code editor so that opening a different +// code block can decide whether it is safe to auto-close-and-replace it +// (issue #1390, part 1). Only one Monaco editor can be open at a time. +export type MonacoSession = { + // Unique per editor instance, used to guard the onDestroy cleanup against + // a stale instance clearing the session after a newer one registered. + token: object; + // The action currently being edited. + action: GridAction; + // True while the editor has unsaved changes or an error ("needs committing"). + // Kept live by the editor so subscribers react as the user types. + dirty: boolean; + // Closes the editor's modal window. + close: () => void; +}; + +export const activeMonacoSession = writable(null); diff --git a/src/renderer/main/panels/FileManager/FileManager.svelte b/src/renderer/main/panels/FileManager/FileManager.svelte index 26233f406..a75ea721c 100644 --- a/src/renderer/main/panels/FileManager/FileManager.svelte +++ b/src/renderer/main/panels/FileManager/FileManager.svelte @@ -226,6 +226,17 @@ let monacoElement: HTMLElement; let editor: MonacoEditor.CustomCodeEditor; + let saveButton: HTMLElement; + + // Ctrl/Cmd+S saves the file by triggering the Save button, which no-ops on + // its own when disabled. Scoped to the editor section's subtree. + function handleKeydown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + e.stopPropagation(); + saveButton?.querySelector("button")?.click(); + } + } const languageOptions = [ { title: "Plain Text", value: "plaintext" }, @@ -342,10 +353,16 @@ rawContent = assembled; selectedLanguage = detectLanguage(entry); - fileContent = - selectedLanguage === "lua" - ? GridScript.expandScript(rawContent) - : rawContent; + try { + fileContent = + selectedLanguage === "lua" + ? GridScript.expandScript(rawContent) + : rawContent; + luaSyntaxError = null; + } catch (e) { + fileContent = rawContent; + luaSyntaxError = String(e); + } savedContent = fileContent; editor?.setValue(fileContent ?? ""); } catch (e) { @@ -673,7 +690,11 @@
{#if error} -

{error}

+

+ {error} +

{:else if loading}

Loading...

{:else if entries.length === 0} @@ -702,6 +723,7 @@ {/if}
- +
+ +
- {#if luaSyntaxError} -

{luaSyntaxError}

- {/if} {#if readingFile}

{downloadProgress @@ -755,5 +776,12 @@ ? 'hidden' : ''}" /> + {#if luaSyntaxError} +

+ {luaSyntaxError} +

+ {/if}
diff --git a/src/renderer/main/panels/configuration/ActionList.svelte b/src/renderer/main/panels/configuration/ActionList.svelte index e30249633..d046f19fa 100644 --- a/src/renderer/main/panels/configuration/ActionList.svelte +++ b/src/renderer/main/panels/configuration/ActionList.svelte @@ -5,8 +5,34 @@ import DynamicWrapper from "./components/DynamicWrapper.svelte"; import { GridEvent, GridRuntime } from "./../../../runtime/runtime"; import { fade } from "svelte/transition"; - import { flip } from "svelte/animate"; import * as eases from "svelte/easing"; + + // FLIP-style animation that only translates (unlike svelte/animate's `flip`, + // which also scales). Scaling squishes the contents of any block whose height + // changes mid-list — e.g. a code block growing to show a syntax error — so we + // translate position changes only and let resized blocks grow in place. + function reflow( + node: Element, + { from, to }: { from: DOMRect; to: DOMRect }, + params: { + delay?: number; + duration?: number; + easing?: (t: number) => number; + } = {}, + ) { + const style = getComputedStyle(node); + const transform = style.transform === "none" ? "" : style.transform; + const dx = from.left - to.left; + const dy = from.top - to.top; + const { delay = 0, duration = 300, easing = eases.cubicOut } = params; + return { + delay, + duration, + easing, + css: (_t: number, u: number) => + `transform: ${transform} translate(${u * dx}px, ${u * dy}px);`, + }; + } import { addActions, pasteActions } from "../../../runtime/operations"; import { GridAction } from "./../../../runtime/runtime"; import { appSettings } from "./../../../runtime/app-helper.store"; @@ -168,7 +194,7 @@
diff --git a/src/renderer/main/user-interface/CharacterCount.svelte b/src/renderer/main/user-interface/CharacterCount.svelte new file mode 100644 index 000000000..ac6a12ebd --- /dev/null +++ b/src/renderer/main/user-interface/CharacterCount.svelte @@ -0,0 +1,12 @@ + + + + {`Character Count: ${ + typeof scriptLength === "undefined" ? "?" : scriptLength + }/${Grid.Protocol.maxScriptLength - 1} (max)`} + diff --git a/src/renderer/main/user-interface/CodeEditor.svelte b/src/renderer/main/user-interface/CodeEditor.svelte new file mode 100644 index 000000000..0ed159dcd --- /dev/null +++ b/src/renderer/main/user-interface/CodeEditor.svelte @@ -0,0 +1,202 @@ + + +
diff --git a/src/renderer/main/user-interface/CommitStatus.svelte b/src/renderer/main/user-interface/CommitStatus.svelte new file mode 100644 index 000000000..557ed21ce --- /dev/null +++ b/src/renderer/main/user-interface/CommitStatus.svelte @@ -0,0 +1,17 @@ + + +{#if deleted} +
Deleted Action
+{:else} +
+ {commitEnabled ? "Unsaved changes!" : "Synced with Grid!"} +
+{/if} diff --git a/src/renderer/main/user-interface/LineEditor.svelte b/src/renderer/main/user-interface/LineEditor.svelte index df62df8b6..71d730fc9 100644 --- a/src/renderer/main/user-interface/LineEditor.svelte +++ b/src/renderer/main/user-interface/LineEditor.svelte @@ -148,7 +148,7 @@
+
{#each $logStreamStore as log, i (log)}
diff --git a/src/renderer/main/user-interface/cursor-log/LogStream.store.js b/src/renderer/main/user-interface/cursor-log/LogStream.store.js index 77ca6cb33..2e8771380 100644 --- a/src/renderer/main/user-interface/cursor-log/LogStream.store.js +++ b/src/renderer/main/user-interface/cursor-log/LogStream.store.js @@ -66,9 +66,18 @@ function createLogStream() { } }); + // Force the log stream (and the underlying logger) back to an empty state, + // cancelling any pending auto-clear. Used to get rid of "bleeding" toasts + // that would otherwise linger for up to 5s and overlay later interactions. + function reset() { + clearTimeout(logClearTimeout); + clearLogs(true); + } + return { ...logStream, dismissLog: dismissLog, enableTimeout: enableTimeout, + reset: reset, }; }