From eb16ef29ce0038c2a688df3b1a06e7cae9d84835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:32:03 +0200 Subject: [PATCH 01/13] docs(settings): specify key format cursor fix --- .../2026-07-10-key-format-cursor-design.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-10-key-format-cursor-design.md diff --git a/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md b/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md new file mode 100644 index 00000000..71d63272 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md @@ -0,0 +1,26 @@ +# Key format cursor fix + +## Problem + +The key-format editor starts completion on every `keydown`, before CodeMirror +has applied the typed character. Its custom completion transaction also bypasses +CodeMirror's built-in completion lifecycle. After inserting a placeholder, the +next separator or placeholder can therefore use a stale completion range and +move the cursor unexpectedly. + +## Design + +- Keep click-to-open completion. +- Remove the editor wrapper's `onKeyDown` completion trigger. CodeMirror already + activates completion for normal typing. +- Represent each placeholder's `apply` value as the inserted string so CodeMirror + owns the document change, selection mapping, and completion closing. +- Do not change placeholder names, formatting, preview generation, or persistence. + +## Verification + +Add a Cypress regression that opens the key-format editor, inserts a placeholder, +types `.` at the cursor, inserts another placeholder, and verifies the separator +remains between the two placeholders. Run the focused test first to prove the +current implementation fails, then run it again after the fix together with the +TypeScript and build checks. From a48923aa20dd27aac38a606918f737c5a39061f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:37:30 +0200 Subject: [PATCH 02/13] chore(git): ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b17c95e9..b3fdc1a3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ cypress/videos cypress/screenshots .vscode +.worktrees From 8c6b09a039b54a1c40faade1203da5c591998037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:47:49 +0200 Subject: [PATCH 03/13] fix(settings): preserve key format cursor position --- cypress/e2e/keyFormatEditor.cy.ts | 36 +++++ .../plans/2026-07-10-key-format-cursor.md | 132 ++++++++++++++++++ src/ui/views/Settings/StringsEditor.tsx | 20 +-- 3 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 cypress/e2e/keyFormatEditor.cy.ts create mode 100644 docs/superpowers/plans/2026-07-10-key-format-cursor.md diff --git a/cypress/e2e/keyFormatEditor.cy.ts b/cypress/e2e/keyFormatEditor.cy.ts new file mode 100644 index 00000000..3e1afebf --- /dev/null +++ b/cypress/e2e/keyFormatEditor.cy.ts @@ -0,0 +1,36 @@ +import { DEFAULT_CREDENTIALS } from "@/web/urlConfig"; +import { visitWithState } from "../common/tools"; + +describe("Key format editor", () => { + it("keeps separators between inserted placeholders", () => { + visitWithState({ + config: { + apiUrl: DEFAULT_CREDENTIALS.apiUrl, + apiKey: DEFAULT_CREDENTIALS.apiKey, + language: "en", + namespace: "", + documentInfo: true, + pageInfo: true, + prefillKeyFormat: true, + keyFormat: "", + }, + }); + + cy.iframe().findDcy("index_settings_button").click(); + cy.iframe().findDcy("settings_expandable_strings").click(); + cy.iframe().findDcy("global-editor").click(); + cy.iframe().find(".cm-content").as("editorContent"); + cy.iframe().find(".cm-tooltip-autocomplete").should("be.visible"); + cy.wait(150); + + cy.get("@editorContent").type("{enter}").should("have.text", "artboard"); + cy.iframe().find(".cm-tooltip-autocomplete").should("not.exist"); + cy.get("@editorContent").type(".component"); + cy.iframe().find(".cm-tooltip-autocomplete").should("be.visible"); + cy.wait(150); + cy.get("@editorContent") + .type("{enter}") + .should("have.text", "artboard.component"); + cy.iframe().find(".cm-tooltip-autocomplete").should("not.exist"); + }); +}); diff --git a/docs/superpowers/plans/2026-07-10-key-format-cursor.md b/docs/superpowers/plans/2026-07-10-key-format-cursor.md new file mode 100644 index 00000000..b77bb123 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-key-format-cursor.md @@ -0,0 +1,132 @@ +# Key Format Cursor Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep separators and the cursor at the intended position when users combine multiple placeholders in the key-format editor. + +**Architecture:** Let CodeMirror own completion insertion, selection mapping, and completion closing. Open completion from a CodeMirror content click handler instead of restarting it from every bubbled click and keydown event. + +**Tech Stack:** TypeScript, Preact, CodeMirror 6, Cypress, Bun + +## Global Constraints + +- Keep click-to-open completion. +- Do not change placeholder names, formatting, preview generation, or persistence. +- Add no dependencies or new abstractions. +- Use Bun for project commands. + +--- + +### Task 1: Preserve the cursor between key-format placeholders + +**Files:** + +- Create: `cypress/e2e/keyFormatEditor.cy.ts` +- Modify: `src/ui/views/Settings/StringsEditor.tsx:93-108,163-193,227-242` + +**Interfaces:** + +- Consumes: CodeMirror `Completion.apply`, `EditorView.domEventHandlers`, and the existing `StringsEditor` props. +- Produces: unchanged `StringsEditor` props and unchanged key-format strings through `onChange`. + +- [x] **Step 1: Write the failing Cypress regression** + +```typescript +import { DEFAULT_CREDENTIALS } from "@/web/urlConfig"; +import { visitWithState } from "../common/tools"; + +describe("Key format editor", () => { + it("keeps separators between inserted placeholders", () => { + visitWithState({ + config: { + apiUrl: DEFAULT_CREDENTIALS.apiUrl, + apiKey: DEFAULT_CREDENTIALS.apiKey, + language: "en", + namespace: "", + documentInfo: true, + pageInfo: true, + prefillKeyFormat: true, + keyFormat: "", + }, + }); + + cy.iframe().findDcy("index_settings_button").click(); + cy.iframe().findDcy("settings_expandable_strings").click(); + cy.iframe().findDcy("global-editor").click(); + cy.iframe().find(".cm-content").as("editorContent"); + cy.iframe().find(".cm-tooltip-autocomplete").should("be.visible"); + cy.wait(150); + + cy.get("@editorContent").type("{enter}").should("have.text", "artboard"); + cy.iframe().find(".cm-tooltip-autocomplete").should("not.exist"); + cy.get("@editorContent").type(".component"); + cy.iframe().find(".cm-tooltip-autocomplete").should("be.visible"); + cy.wait(150); + cy.get("@editorContent") + .type("{enter}") + .should("have.text", "artboard.component"); + cy.iframe().find(".cm-tooltip-autocomplete").should("not.exist"); + }); +}); +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +bunx cypress run --spec cypress/e2e/keyFormatEditor.cy.ts +``` + +Expected: FAIL because `.cm-tooltip-autocomplete` still exists after accepting the first placeholder, demonstrating that completion was restarted by the wrapper event. + +- [x] **Step 3: Delegate insertion and click handling to CodeMirror** + +Replace `createCompletionOption` with CodeMirror's string `apply` contract: + +```typescript +function createCompletionOption( + label: string, + detail: string, + insertText: string, +) { + return { + label, + detail, + type: "keyword" as const, + apply: insertText, + }; +} +``` + +Add a content-scoped click handler to the editor extensions: + +```typescript +EditorView.domEventHandlers({ + click(_event, view) { + startCompletion(view); + return false; + }, +}), +``` + +Remove the Preact `onClick` and `onKeyDown` props from the editor wrapper. This prevents completion-option clicks and ordinary key presses from starting a stale explicit completion. + +- [x] **Step 4: Run focused and static verification** + +Run: + +```bash +bunx cypress run --spec cypress/e2e/keyFormatEditor.cy.ts +bun run tsc +bun run build +``` + +Expected: the Cypress spec passes, TypeScript reports no errors, and the plugin build completes successfully. + +- [x] **Step 5: Commit the bug fix** + +```bash +git add cypress/e2e/keyFormatEditor.cy.ts src/ui/views/Settings/StringsEditor.tsx docs/superpowers/plans/2026-07-10-key-format-cursor.md +git commit -m "fix(settings): preserve key format cursor position" +``` diff --git a/src/ui/views/Settings/StringsEditor.tsx b/src/ui/views/Settings/StringsEditor.tsx index becc3610..e3c3761a 100644 --- a/src/ui/views/Settings/StringsEditor.tsx +++ b/src/ui/views/Settings/StringsEditor.tsx @@ -99,13 +99,7 @@ export const StringsEditor = ({ label, detail, type: "keyword" as const, - apply(view: EditorView, _: any, from: number, to: number) { - const insert = insertText; - view.dispatch({ - changes: { from, to, insert }, - selection: { anchor: from + insert.length }, - }); - }, + apply: insertText, }; } @@ -174,6 +168,12 @@ export const StringsEditor = ({ callbacksRef.current?.onChange?.(v.state.doc.toString()); } }), + EditorView.domEventHandlers({ + click(_event, view) { + startCompletion(view); + return false; + }, + }), placeholders.current.of( PlaceholderPlugin({ examplePluralNum: 1, @@ -227,12 +227,6 @@ export const StringsEditor = ({ return (
{ - startCompletion(editor.current!); - }} - onKeyDown={() => { - startCompletion(editor.current!); - }} class="strings-editor" data-cy="global-editor" ref={ref} From ecd09b5b1f6301c9d79ae52023546995307a63c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:44:18 +0200 Subject: [PATCH 04/13] docs(settings): specify badge cursor rendering --- .../2026-07-10-key-format-cursor-design.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md b/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md index 71d63272..5e51822d 100644 --- a/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md +++ b/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md @@ -8,6 +8,10 @@ CodeMirror's built-in completion lifecycle. After inserting a placeholder, the next separator or placeholder can therefore use a stale completion range and move the cursor unexpectedly. +After accepting a placeholder, the logical cursor position is correct, but the +caret is rendered on the left side of the placeholder badge. The same visual +position is shown when the cursor is logically before or after the badge. + ## Design - Keep click-to-open completion. @@ -17,6 +21,23 @@ move the cursor unexpectedly. owns the document change, selection mapping, and completion closing. - Do not change placeholder names, formatting, preview generation, or persistence. +### Placeholder caret association + +The placeholder is rendered as a CodeMirror widget with `side: 1`. CodeMirror +uses a cursor range's `assoc` value to choose the visual side at an ambiguous +widget boundary. A completion currently leaves `assoc` at its default value, so +the caret is drawn on the badge's left edge even though the cursor is logically +after it. + +Apply placeholder completions with a cursor range whose `assoc` is `-1`. This +associates the cursor with the preceding content and renders it on the right +edge of the badge. Preserve CodeMirror's `pickedCompletion` annotation so its +completion lifecycle and events continue to behave normally. + +Arrow-key movement remains owned by CodeMirror. Consider a local cursor overlay +only if the regression test shows that CodeMirror does not preserve the correct +association while crossing the atomic placeholder. + ## Verification Add a Cypress regression that opens the key-format editor, inserts a placeholder, @@ -24,3 +45,9 @@ types `.` at the cursor, inserts another placeholder, and verifies the separator remains between the two placeholders. Run the focused test first to prove the current implementation fails, then run it again after the fix together with the TypeScript and build checks. + +Also assert the primary caret's position relative to the placeholder badge: + +- directly after accepting a placeholder, the caret is at the badge's right edge; +- after moving left across the placeholder, the caret is at its left edge; +- after moving right across the placeholder, the caret is again at its right edge. From 2def9c59992c5fb50cfd657379fc267e58bed1eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:07:46 +0200 Subject: [PATCH 05/13] fix(settings): render cursor after placeholder badge --- .../plans/2026-07-10-key-format-cursor.md | 74 ++++++++++++++++++- .../2026-07-10-key-format-cursor-design.md | 5 +- src/ui/views/Settings/StringsEditor.tsx | 19 ++++- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-07-10-key-format-cursor.md b/docs/superpowers/plans/2026-07-10-key-format-cursor.md index b77bb123..f84ba1eb 100644 --- a/docs/superpowers/plans/2026-07-10-key-format-cursor.md +++ b/docs/superpowers/plans/2026-07-10-key-format-cursor.md @@ -4,7 +4,10 @@ **Goal:** Keep separators and the cursor at the intended position when users combine multiple placeholders in the key-format editor. -**Architecture:** Let CodeMirror own completion insertion, selection mapping, and completion closing. Open completion from a CodeMirror content click handler instead of restarting it from every bubbled click and keydown event. +**Architecture:** Use CodeMirror's completion lifecycle and content click handler +instead of restarting completion from bubbled events. The visual-caret follow-up +customizes only the completion transaction's cursor association so CodeMirror +draws the caret on the logical side of an atomic placeholder. **Tech Stack:** TypeScript, Preact, CodeMirror 6, Cypress, Bun @@ -14,6 +17,8 @@ - Do not change placeholder names, formatting, preview generation, or persistence. - Add no dependencies or new abstractions. - Use Bun for project commands. +- Do not add or run Cypress coverage for the visual-caret follow-up; the reporter + will verify it manually. --- @@ -130,3 +135,70 @@ Expected: the Cypress spec passes, TypeScript reports no errors, and the plugin git add cypress/e2e/keyFormatEditor.cy.ts src/ui/views/Settings/StringsEditor.tsx docs/superpowers/plans/2026-07-10-key-format-cursor.md git commit -m "fix(settings): preserve key format cursor position" ``` + +### Task 2: Render the caret on the logical side of a placeholder + +**Files:** + +- Modify: `src/ui/views/Settings/StringsEditor.tsx:3,16-21,93-106` +- Modify: `docs/superpowers/specs/2026-07-10-key-format-cursor-design.md:37-53` + +**Interfaces:** + +- Consumes: CodeMirror `Completion`, `EditorSelection.cursor`, + `pickedCompletion`, and the existing placeholder completion range. +- Produces: the existing completion option shape with a custom `apply` function + that places the cursor at `from + insertText.length` with `assoc: -1`. + +- [x] **Step 1: Apply the completion with an explicit cursor association** + +Import the selection and completion APIs: + +```typescript +import { Compartment, EditorSelection, EditorState } from "@codemirror/state"; +import { + autocompletion, + Completion, + CompletionContext, + CompletionResult, + pickedCompletion, + startCompletion, +} from "@codemirror/autocomplete"; +``` + +Replace the string `apply` value in `createCompletionOption` with a typed +function that preserves CodeMirror's completion annotation: + +```typescript +apply(view: EditorView, completion: Completion, from: number, to: number) { + view.dispatch({ + changes: { from, to, insert: insertText }, + selection: EditorSelection.cursor(from + insertText.length, -1), + annotations: pickedCompletion.of(completion), + scrollIntoView: true, + userEvent: "input.complete", + }); +}, +``` + +- [x] **Step 2: Run static verification** + +Run: + +```bash +bun run tsc +bun run build +``` + +Expected: TypeScript reports no errors and the plugin build completes +successfully. Do not run Cypress; the reporter will verify left/right caret +rendering in Figma. + +- [x] **Step 3: Commit the follow-up fix** + +```bash +git add src/ui/views/Settings/StringsEditor.tsx \ + docs/superpowers/specs/2026-07-10-key-format-cursor-design.md \ + docs/superpowers/plans/2026-07-10-key-format-cursor.md +git commit -m "fix(settings): render cursor after placeholder badge" +``` diff --git a/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md b/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md index 5e51822d..4cfdce74 100644 --- a/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md +++ b/docs/superpowers/specs/2026-07-10-key-format-cursor-design.md @@ -35,7 +35,7 @@ edge of the badge. Preserve CodeMirror's `pickedCompletion` annotation so its completion lifecycle and events continue to behave normally. Arrow-key movement remains owned by CodeMirror. Consider a local cursor overlay -only if the regression test shows that CodeMirror does not preserve the correct +only if manual verification shows that CodeMirror does not preserve the correct association while crossing the atomic placeholder. ## Verification @@ -46,7 +46,8 @@ remains between the two placeholders. Run the focused test first to prove the current implementation fails, then run it again after the fix together with the TypeScript and build checks. -Also assert the primary caret's position relative to the placeholder badge: +The reporter will manually verify the primary caret's position relative to the +placeholder badge; do not add or run Cypress coverage for this follow-up: - directly after accepting a placeholder, the caret is at the badge's right edge; - after moving left across the placeholder, the caret is at its left edge; diff --git a/src/ui/views/Settings/StringsEditor.tsx b/src/ui/views/Settings/StringsEditor.tsx index e3c3761a..cc9630c9 100644 --- a/src/ui/views/Settings/StringsEditor.tsx +++ b/src/ui/views/Settings/StringsEditor.tsx @@ -1,7 +1,7 @@ import { h, RefObject } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { minimalSetup } from "codemirror"; -import { Compartment, EditorState } from "@codemirror/state"; +import { Compartment, EditorSelection, EditorState } from "@codemirror/state"; import { ViewUpdate, EditorView, @@ -13,8 +13,10 @@ import { PlaceholderPlugin } from "@tginternal/editor"; import "!./StringsEditor.css"; import { autocompletion, + Completion, CompletionContext, CompletionResult, + pickedCompletion, startCompletion, } from "@codemirror/autocomplete"; @@ -99,7 +101,20 @@ export const StringsEditor = ({ label, detail, type: "keyword" as const, - apply: insertText, + apply( + view: EditorView, + completion: Completion, + from: number, + to: number, + ) { + view.dispatch({ + changes: { from, to, insert: insertText }, + selection: EditorSelection.cursor(from + insertText.length, -1), + annotations: pickedCompletion.of(completion), + scrollIntoView: true, + userEvent: "input.complete", + }); + }, }; } From 4cad1bcca3973336eee4c18dfae5db4a853a1f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:55:05 +0200 Subject: [PATCH 06/13] test(settings): stabilize key format editor setup --- cypress/e2e/keyFormatEditor.cy.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/keyFormatEditor.cy.ts b/cypress/e2e/keyFormatEditor.cy.ts index 3e1afebf..37d6f472 100644 --- a/cypress/e2e/keyFormatEditor.cy.ts +++ b/cypress/e2e/keyFormatEditor.cy.ts @@ -16,9 +16,15 @@ describe("Key format editor", () => { }, }); - cy.iframe().findDcy("index_settings_button").click(); - cy.iframe().findDcy("settings_expandable_strings").click(); - cy.iframe().findDcy("global-editor").click(); + cy.iframe() + .find('[data-cy="index_settings_button"]') + .should("be.visible") + .click(); + cy.iframe() + .find('[data-cy="settings_expandable_strings"]') + .should("be.visible") + .click(); + cy.iframe().find('[data-cy="global-editor"]').should("be.visible").click(); cy.iframe().find(".cm-content").as("editorContent"); cy.iframe().find(".cm-tooltip-autocomplete").should("be.visible"); cy.wait(150); From 4ee86ebbaefe8f723e14a77978d5b3d2d1c35004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:32:22 +0200 Subject: [PATCH 07/13] test(cypress): avoid stale iframe waits --- cypress/common/tools.ts | 12 ++++++++++-- cypress/e2e/keyFormatEditor.cy.ts | 19 +++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/cypress/common/tools.ts b/cypress/common/tools.ts index 7038d7dd..ceabc612 100644 --- a/cypress/common/tools.ts +++ b/cypress/common/tools.ts @@ -4,6 +4,14 @@ const ORIGIN = "http://localhost:3000"; export const visitWithState = (data: Partial) => { cy.visit(`${ORIGIN}${createShortcutUrl(data)}`); - cy.wait(100); - cy.frameLoaded("#plugin_iframe"); + cy.get("#plugin_iframe", { timeout: 30000 }).should( + ($iframe) => { + const body = $iframe[0]?.contentDocument?.body; + expect(body, "plugin iframe body").to.exist; + expect( + body!.childElementCount, + "plugin iframe content", + ).to.be.greaterThan(0); + }, + ); }; diff --git a/cypress/e2e/keyFormatEditor.cy.ts b/cypress/e2e/keyFormatEditor.cy.ts index 37d6f472..ac8d72c4 100644 --- a/cypress/e2e/keyFormatEditor.cy.ts +++ b/cypress/e2e/keyFormatEditor.cy.ts @@ -16,27 +16,30 @@ describe("Key format editor", () => { }, }); - cy.iframe() + cy.iframeBody() .find('[data-cy="index_settings_button"]') .should("be.visible") .click(); - cy.iframe() + cy.iframeBody() .find('[data-cy="settings_expandable_strings"]') .should("be.visible") .click(); - cy.iframe().find('[data-cy="global-editor"]').should("be.visible").click(); - cy.iframe().find(".cm-content").as("editorContent"); - cy.iframe().find(".cm-tooltip-autocomplete").should("be.visible"); + cy.iframeBody() + .find('[data-cy="global-editor"]') + .should("be.visible") + .click(); + cy.iframeBody().find(".cm-content").as("editorContent"); + cy.iframeBody().find(".cm-tooltip-autocomplete").should("be.visible"); cy.wait(150); cy.get("@editorContent").type("{enter}").should("have.text", "artboard"); - cy.iframe().find(".cm-tooltip-autocomplete").should("not.exist"); + cy.iframeBody().find(".cm-tooltip-autocomplete").should("not.exist"); cy.get("@editorContent").type(".component"); - cy.iframe().find(".cm-tooltip-autocomplete").should("be.visible"); + cy.iframeBody().find(".cm-tooltip-autocomplete").should("be.visible"); cy.wait(150); cy.get("@editorContent") .type("{enter}") .should("have.text", "artboard.component"); - cy.iframe().find(".cm-tooltip-autocomplete").should("not.exist"); + cy.iframeBody().find(".cm-tooltip-autocomplete").should("not.exist"); }); }); From 94919c071bce2fda214caf3c6decb34c0542a922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:53:14 +0200 Subject: [PATCH 08/13] test(settings): skip unstable editor e2e --- cypress/e2e/keyFormatEditor.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/keyFormatEditor.cy.ts b/cypress/e2e/keyFormatEditor.cy.ts index ac8d72c4..9e9c933f 100644 --- a/cypress/e2e/keyFormatEditor.cy.ts +++ b/cypress/e2e/keyFormatEditor.cy.ts @@ -1,7 +1,7 @@ import { DEFAULT_CREDENTIALS } from "@/web/urlConfig"; import { visitWithState } from "../common/tools"; -describe("Key format editor", () => { +describe.skip("Key format editor", () => { it("keeps separators between inserted placeholders", () => { visitWithState({ config: { From 1edc14f9f2926c2c0ee04ad208b7bed09202bde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:58:42 +0200 Subject: [PATCH 09/13] test(cypress): restore iframe setup --- cypress/common/tools.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/cypress/common/tools.ts b/cypress/common/tools.ts index ceabc612..7038d7dd 100644 --- a/cypress/common/tools.ts +++ b/cypress/common/tools.ts @@ -4,14 +4,6 @@ const ORIGIN = "http://localhost:3000"; export const visitWithState = (data: Partial) => { cy.visit(`${ORIGIN}${createShortcutUrl(data)}`); - cy.get("#plugin_iframe", { timeout: 30000 }).should( - ($iframe) => { - const body = $iframe[0]?.contentDocument?.body; - expect(body, "plugin iframe body").to.exist; - expect( - body!.childElementCount, - "plugin iframe content", - ).to.be.greaterThan(0); - }, - ); + cy.wait(100); + cy.frameLoaded("#plugin_iframe"); }; From c6aeba8ac3bf6a2cf44bd0bc3308d00df3fc807f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:56:42 +0200 Subject: [PATCH 10/13] fix(settings): render caret on both sides of placeholder badge --- src/ui/views/Settings/StringsEditor.tsx | 83 ++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/src/ui/views/Settings/StringsEditor.tsx b/src/ui/views/Settings/StringsEditor.tsx index cc9630c9..d1879985 100644 --- a/src/ui/views/Settings/StringsEditor.tsx +++ b/src/ui/views/Settings/StringsEditor.tsx @@ -1,15 +1,21 @@ import { h, RefObject } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { minimalSetup } from "codemirror"; -import { Compartment, EditorSelection, EditorState } from "@codemirror/state"; +import { + EditorSelection, + EditorState, + RangeSetBuilder, + StateField, +} from "@codemirror/state"; import { ViewUpdate, EditorView, KeyBinding, Decoration, + DecorationSet, WidgetType, } from "@codemirror/view"; -import { PlaceholderPlugin } from "@tginternal/editor"; +import { getPlaceholders, Placeholder } from "@tginternal/editor"; import "!./StringsEditor.css"; import { autocompletion, @@ -20,6 +26,70 @@ import { startCompletion, } from "@codemirror/autocomplete"; +class PlaceholderBadgeWidget extends WidgetType { + constructor(private value: Placeholder) { + super(); + } + + eq(other: PlaceholderBadgeWidget) { + return ( + other.value.name === this.value.name && + other.value.type === this.value.type && + other.value.error === this.value.error + ); + } + + toDOM() { + const outer = document.createElement("span"); + let classes = `placeholder-widget placeholder-${this.value.type}`; + if (this.value.error) { + classes += ` placeholder-error placeholder-error-${this.value.error}`; + } + outer.className = classes; + const inner = document.createElement("span"); + inner.textContent = this.value.name; + outer.appendChild(inner); + return outer; + } +} + +// Renders {placeholder} ranges as badges. Replaces PlaceholderPlugin from +// @tginternal/editor, whose Decoration.widget({side: 1}) ranges place both +// boundary cursor positions inside the widget, so the caret is always drawn +// at the badge's left edge. Non-inclusive Decoration.replace keeps the +// positions before/after the badge, so the caret renders on the correct side. +function placeholderBadges() { + const build = (state: EditorState) => { + const builder = new RangeSetBuilder(); + let placeholders: Placeholder[] | null = []; + try { + placeholders = getPlaceholders(state.doc.toString(), true); + } catch (e) { + placeholders = []; + } + for (const placeholder of placeholders ?? []) { + builder.add( + placeholder.position.start, + placeholder.position.end, + Decoration.replace({ + widget: new PlaceholderBadgeWidget(placeholder), + }), + ); + } + return builder.finish(); + }; + const field = StateField.define({ + create: build, + update: (set, transaction) => + transaction.docChanged ? build(transaction.state) : set, + provide: (f) => [ + EditorView.decorations.from(f), + EditorView.atomicRanges.of((view) => view.state.field(f)), + ], + }); + return field; +} + // Custom placeholder widget class PlaceholderWidget extends WidgetType { constructor(private placeholder: string) { @@ -85,7 +155,6 @@ export const StringsEditor = ({ }: EditorProps) => { const ref = useRef(null); const editor = useRef(); - const placeholders = useRef(new Compartment()); const callbacksRef = useRefGroup({ onChange, onFocus, @@ -189,13 +258,7 @@ export const StringsEditor = ({ return false; }, }), - placeholders.current.of( - PlaceholderPlugin({ - examplePluralNum: 1, - nested: true, - tooltips: false, - }), - ), + placeholderBadges(), autocompletion({ override: [completions], optionClass: () => { From 555c1047f005b15788cf504dafc3ba32831966b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:30:40 +0200 Subject: [PATCH 11/13] Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/ui/views/Settings/StringsEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/views/Settings/StringsEditor.tsx b/src/ui/views/Settings/StringsEditor.tsx index d1879985..3d5ab258 100644 --- a/src/ui/views/Settings/StringsEditor.tsx +++ b/src/ui/views/Settings/StringsEditor.tsx @@ -64,7 +64,7 @@ function placeholderBadges() { let placeholders: Placeholder[] | null = []; try { placeholders = getPlaceholders(state.doc.toString(), true); - } catch (e) { + } catch { placeholders = []; } for (const placeholder of placeholders ?? []) { From e8ec79a04d71babfe59dcbdb71c25ab67c5a3b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:55:00 +0200 Subject: [PATCH 12/13] fix(settings): preserve placeholder cursor interactions --- cypress/e2e/keyFormatEditor.cy.ts | 131 +++++++++++++++++------- src/ui/views/Settings/StringsEditor.tsx | 8 +- 2 files changed, 102 insertions(+), 37 deletions(-) diff --git a/cypress/e2e/keyFormatEditor.cy.ts b/cypress/e2e/keyFormatEditor.cy.ts index 9e9c933f..158f5f4c 100644 --- a/cypress/e2e/keyFormatEditor.cy.ts +++ b/cypress/e2e/keyFormatEditor.cy.ts @@ -1,45 +1,104 @@ import { DEFAULT_CREDENTIALS } from "@/web/urlConfig"; +import { EditorView } from "@codemirror/view"; import { visitWithState } from "../common/tools"; -describe.skip("Key format editor", () => { - it("keeps separators between inserted placeholders", () => { - visitWithState({ - config: { - apiUrl: DEFAULT_CREDENTIALS.apiUrl, - apiKey: DEFAULT_CREDENTIALS.apiKey, - language: "en", - namespace: "", - documentInfo: true, - pageInfo: true, - prefillKeyFormat: true, - keyFormat: "", - }, - }); +function openEditor() { + visitWithState({ + config: { + apiUrl: DEFAULT_CREDENTIALS.apiUrl, + apiKey: DEFAULT_CREDENTIALS.apiKey, + language: "en", + namespace: "", + documentInfo: true, + pageInfo: true, + prefillKeyFormat: true, + keyFormat: "", + }, + }); + + cy.iframeBody() + .find('[data-cy="index_settings_button"]') + .should("be.visible") + .click(); + cy.iframeBody() + .find('[data-cy="settings_expandable_strings"]') + .should("be.visible") + .click(); + cy.iframeBody() + .find('[data-cy="global-editor"]') + .should("be.visible") + .click(); + cy.iframeBody().find(".cm-content").as("editorContent"); + cy.iframeBody().find(".cm-tooltip-autocomplete").should("be.visible"); +} - cy.iframeBody() - .find('[data-cy="index_settings_button"]') - .should("be.visible") - .click(); - cy.iframeBody() - .find('[data-cy="settings_expandable_strings"]') - .should("be.visible") - .click(); - cy.iframeBody() - .find('[data-cy="global-editor"]') - .should("be.visible") - .click(); - cy.iframeBody().find(".cm-content").as("editorContent"); +function getEditorView(): Cypress.Chainable { + return cy.get("@editorContent").then(($content) => { + const view = EditorView.findFromDOM($content[0]); + expect(view).not.to.be.null; + return view!; + }); +} + +function acceptSelectedCompletion(): Cypress.Chainable { + cy.wait(200); + return getEditorView() + .then((view) => { + const frameWindow = view.contentDOM.ownerDocument.defaultView; + expect(frameWindow).not.to.be.null; + view.contentDOM.dispatchEvent( + new frameWindow!.KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + bubbles: true, + cancelable: true, + }), + ); + return view.state.selection.main.assoc; + }) + .then((assoc) => + cy + .iframeBody() + .find(".cm-tooltip-autocomplete") + .should("not.exist") + .then(() => assoc), + ); +} + +describe("Key format editor", () => { + it("preserves cursor association after completion", () => { + openEditor(); + acceptSelectedCompletion().should("equal", -1); + }); + + it("opens completion when a placeholder badge is clicked", () => { + openEditor(); + acceptSelectedCompletion(); + + cy.iframeBody().find(".placeholder-widget").click(); cy.iframeBody().find(".cm-tooltip-autocomplete").should("be.visible"); - cy.wait(150); + }); - cy.get("@editorContent").type("{enter}").should("have.text", "artboard"); - cy.iframeBody().find(".cm-tooltip-autocomplete").should("not.exist"); - cy.get("@editorContent").type(".component"); + it("keeps separators between inserted placeholders", () => { + openEditor(); + acceptSelectedCompletion(); + + getEditorView().then((view) => { + const position = view.state.selection.main.head; + const insert = ".component"; + view.dispatch({ + changes: { from: position, insert }, + selection: { anchor: position + insert.length }, + userEvent: "input.type", + }); + }); cy.iframeBody().find(".cm-tooltip-autocomplete").should("be.visible"); - cy.wait(150); - cy.get("@editorContent") - .type("{enter}") - .should("have.text", "artboard.component"); - cy.iframeBody().find(".cm-tooltip-autocomplete").should("not.exist"); + acceptSelectedCompletion(); + + getEditorView() + .its("state.doc") + .invoke("toString") + .should("equal", "{artboard}.{component}"); + cy.get("@editorContent").should("have.text", "artboard.component"); }); }); diff --git a/src/ui/views/Settings/StringsEditor.tsx b/src/ui/views/Settings/StringsEditor.tsx index 3d5ab258..9c198ecf 100644 --- a/src/ui/views/Settings/StringsEditor.tsx +++ b/src/ui/views/Settings/StringsEditor.tsx @@ -51,6 +51,10 @@ class PlaceholderBadgeWidget extends WidgetType { outer.appendChild(inner); return outer; } + + ignoreEvent() { + return false; + } } // Renders {placeholder} ranges as badges. Replaces PlaceholderPlugin from @@ -178,7 +182,9 @@ export const StringsEditor = ({ ) { view.dispatch({ changes: { from, to, insert: insertText }, - selection: EditorSelection.cursor(from + insertText.length, -1), + selection: EditorSelection.create([ + EditorSelection.cursor(from + insertText.length, -1), + ]), annotations: pickedCompletion.of(completion), scrollIntoView: true, userEvent: "input.complete", From 4f79f2c061e5e0cf58c2326a1a4492518332deff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20H=C3=BClscher?= <25116822+eweren@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:02:38 +0200 Subject: [PATCH 13/13] chore(git): remove unrelated worktree ignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index b3fdc1a3..b17c95e9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,3 @@ cypress/videos cypress/screenshots .vscode -.worktrees