From 6f8cb222bdff2ec2107743133c7979ce3e40e014 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 26 Feb 2026 16:13:58 +0100 Subject: [PATCH 1/9] feat: GenAI Shortcuts in PromptInput --- package-lock.json | 77 +- pages/prompt-input/shortcuts.page.tsx | 648 +++++++++++++ .../__snapshots__/documenter.test.ts.snap | 769 ++++++++++++++- .../dropdown/dropdown-fit-handler.ts | 2 +- src/internal/components/dropdown/index.tsx | 27 +- .../components/dropdown/interfaces.ts | 11 + .../__tests__/prompt-input.test.tsx | 2 +- src/prompt-input/components/menu-dropdown.tsx | 69 ++ src/prompt-input/components/textarea-mode.tsx | 31 + src/prompt-input/components/token-mode.tsx | 165 ++++ src/prompt-input/core/constants.ts | 20 + src/prompt-input/core/cursor-manager.ts | 350 +++++++ src/prompt-input/core/event-handlers.ts | 905 ++++++++++++++++++ src/prompt-input/core/menu-state.ts | 216 +++++ src/prompt-input/core/token-engine.ts | 218 +++++ src/prompt-input/core/token-extractor.ts | 216 +++++ src/prompt-input/core/token-renderer.tsx | 407 ++++++++ src/prompt-input/core/type-guards.ts | 52 + src/prompt-input/core/utils.ts | 195 ++++ src/prompt-input/index.tsx | 2 +- src/prompt-input/interfaces.ts | 338 ++++++- src/prompt-input/internal.tsx | 751 +++++++++++++-- src/prompt-input/shortcuts/use-shortcuts.ts | 520 ++++++++++ src/prompt-input/styles.scss | 86 +- src/prompt-input/test-classes/styles.scss | 4 + .../tokens/use-editable-tokens.ts | 377 ++++++++ .../utils/insert-text-content-editable.ts | 141 +++ src/test-utils/dom/prompt-input/index.ts | 159 ++- src/token/internal.tsx | 10 +- 29 files changed, 6606 insertions(+), 162 deletions(-) create mode 100644 pages/prompt-input/shortcuts.page.tsx create mode 100644 src/prompt-input/components/menu-dropdown.tsx create mode 100644 src/prompt-input/components/textarea-mode.tsx create mode 100644 src/prompt-input/components/token-mode.tsx create mode 100644 src/prompt-input/core/constants.ts create mode 100644 src/prompt-input/core/cursor-manager.ts create mode 100644 src/prompt-input/core/event-handlers.ts create mode 100644 src/prompt-input/core/menu-state.ts create mode 100644 src/prompt-input/core/token-engine.ts create mode 100644 src/prompt-input/core/token-extractor.ts create mode 100644 src/prompt-input/core/token-renderer.tsx create mode 100644 src/prompt-input/core/type-guards.ts create mode 100644 src/prompt-input/core/utils.ts create mode 100644 src/prompt-input/shortcuts/use-shortcuts.ts create mode 100644 src/prompt-input/tokens/use-editable-tokens.ts create mode 100644 src/prompt-input/utils/insert-text-content-editable.ts diff --git a/package-lock.json b/package-lock.json index 4a1eea1340..78d883f421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -818,6 +818,7 @@ "version": "7.27.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1271,6 +1272,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1292,6 +1294,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1339,6 +1342,7 @@ "node_modules/@dnd-kit/core": { "version": "6.3.1", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2453,7 +2457,6 @@ "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -2517,7 +2520,6 @@ "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -3636,6 +3638,7 @@ "integrity": "sha512-2kpQq2DD/pRpx3Tal/qRW1SYwcIeQ0iq8li5CJHQgOC+FtPn2BVmuDtzUCgNnpCrbgtfEHqh+iWzxK+Tq6C+RQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bytes-iec": "^3.1.1", "chokidar": "^4.0.3", @@ -4484,6 +4487,7 @@ "version": "9.6.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4685,6 +4689,7 @@ "version": "16.14.34", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4867,6 +4872,7 @@ "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", @@ -4896,6 +4902,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5146,7 +5153,6 @@ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tinyrainbow": "^3.0.3" }, @@ -5160,7 +5166,6 @@ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", @@ -5175,8 +5180,7 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@wdio/config": { "version": "9.24.0", @@ -5251,6 +5255,7 @@ "integrity": "sha512-OmwPKV8c5ecLqo+EkytN7oUeYfNmRI4uOXGIR1ybP7AK5Zz+l9R0dGfoadEuwi1aZXAL0vwuhtq3p0OL3dfqHQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18.20.0" }, @@ -5273,6 +5278,7 @@ "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.1.2", "loglevel": "^1.6.0", @@ -5590,6 +5596,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5655,6 +5662,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6222,6 +6230,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -6340,22 +6349,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, "node_modules/bare-fs": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", @@ -6644,7 +6637,7 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7747,6 +7740,7 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -8362,7 +8356,6 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -9361,6 +9354,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9421,6 +9415,7 @@ "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10017,7 +10012,6 @@ "integrity": "sha512-Bkoqs+39fHwjos51qab7ZWmvZrYNBbzgSAIykH2CrgLOLhHJXzC30DP9lZq2MsmaUsbBnN5c5m8VqAhOHTrCRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/snapshot": "^4.0.16", "deep-eql": "^5.0.2", @@ -10050,7 +10044,6 @@ "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/get-type": "30.1.0" }, @@ -10064,7 +10057,6 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -10078,7 +10070,6 @@ "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", @@ -10097,8 +10088,7 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/expect-webdriverio/node_modules/chalk": { "version": "4.1.2", @@ -10106,7 +10096,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10130,7 +10119,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -10141,7 +10129,6 @@ "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", @@ -10160,7 +10147,6 @@ "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", @@ -10177,7 +10163,6 @@ "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", @@ -10194,7 +10179,6 @@ "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", @@ -10216,7 +10200,6 @@ "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -10232,7 +10215,6 @@ "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -10251,7 +10233,6 @@ "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", @@ -10267,7 +10248,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12913,6 +12893,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -13269,6 +13250,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", @@ -13872,6 +13854,7 @@ "version": "29.7.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", @@ -16478,6 +16461,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17200,6 +17184,7 @@ "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -17631,6 +17616,7 @@ "node_modules/react": { "version": "16.14.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -17643,6 +17629,7 @@ "node_modules/react-dom": { "version": "16.14.0", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -18317,6 +18304,7 @@ "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -19756,6 +19744,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -20508,7 +20497,6 @@ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -20741,7 +20729,8 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/type-check": { "version": "0.4.0", @@ -20889,6 +20878,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21402,6 +21392,7 @@ "integrity": "sha512-LTJt6Z/iDM0ne/4ytd3BykoPv9CuJ+CAILOzlwFeMGn4Mj02i4Bk2Rg9o/jeJ89f52hnv4OPmNjD0e8nzWAy5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", @@ -21455,6 +21446,7 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -21502,6 +21494,7 @@ "version": "5.1.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/pages/prompt-input/shortcuts.page.tsx b/pages/prompt-input/shortcuts.page.tsx new file mode 100644 index 0000000000..3f98b2cb65 --- /dev/null +++ b/pages/prompt-input/shortcuts.page.tsx @@ -0,0 +1,648 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useEffect, useState } from 'react'; + +import { + AppLayout, + Box, + ButtonGroup, + ButtonGroupProps, + Checkbox, + ColumnLayout, + FileTokenGroup, + FormField, + KeyValuePairs, + PromptInput, + PromptInputProps, + SpaceBetween, +} from '~components'; +import { OptionDefinition, OptionGroup } from '~components/internal/components/option/interfaces'; + +import AppContext, { AppContextType } from '../app/app-context'; +import labels from '../app-layout/utils/labels'; +import { i18nStrings } from '../file-upload/shared'; + +const MAX_CHARS = 2000; + +type DemoContext = React.Context< + AppContextType<{ + isDisabled: boolean; + isReadOnly: boolean; + isInvalid: boolean; + hasWarning: boolean; + hasText: boolean; + hasSecondaryContent: boolean; + hasSecondaryActions: boolean; + hasPrimaryActions: boolean; + hasInfiniteMaxRows: boolean; + disableActionButton: boolean; + disableBrowserAutocorrect: boolean; + enableSpellcheck: boolean; + hasName: boolean; + enableAutoFocus: boolean; + }> +>; + +const placeholderText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; + +// Sample data for menus +const firstNames = [ + 'John', + 'Jane', + 'Bob', + 'Alice', + 'Charlie', + 'Diana', + 'Evan', + 'Fiona', + 'George', + 'Hannah', + 'Ian', + 'Julia', + 'Kevin', + 'Laura', + 'Michael', + 'Nina', + 'Oliver', + 'Patricia', + 'Quinn', + 'Rachel', + 'Samuel', + 'Teresa', + 'Uma', + 'Victor', + 'Wendy', + 'Xavier', + 'Yara', + 'Zachary', +]; +const lastNames = [ + 'Smith', + 'Johnson', + 'Williams', + 'Brown', + 'Jones', + 'Garcia', + 'Miller', + 'Davis', + 'Rodriguez', + 'Martinez', + 'Hernandez', + 'Lopez', + 'Gonzalez', + 'Wilson', + 'Anderson', + 'Thomas', + 'Taylor', + 'Moore', + 'Jackson', + 'Martin', + 'Lee', + 'Perez', + 'Thompson', + 'White', + 'Harris', + 'Sanchez', + 'Clark', + 'Ramirez', + 'Lewis', + 'Robinson', +]; +const roles = [ + 'Software Engineer', + 'Senior Software Engineer', + 'Staff Software Engineer', + 'Principal Engineer', + 'Engineering Manager', + 'Senior Engineering Manager', + 'Product Manager', + 'Senior Product Manager', + 'Designer', + 'Senior Designer', + 'UX Researcher', + 'Data Scientist', + 'Senior Data Scientist', + 'DevOps Engineer', + 'Security Engineer', + 'QA Engineer', + 'Technical Writer', + 'Solutions Architect', +]; +const teams = [ + 'Backend Services', + 'Frontend Platform', + 'AI/ML Products', + 'Customer Experience', + 'Design Systems', + 'Component Library', + 'Infrastructure', + 'DevOps', + 'Application Security', + 'Data Platform', + 'Analytics', + 'Mobile Apps', + 'API Gateway', + 'Cloud Services', +]; + +// Generate 50 realistic user options +const mentionOptions: OptionDefinition[] = Array.from({ length: 50 }, (_, i) => { + const firstName = firstNames[i % firstNames.length]; + const lastName = lastNames[Math.floor(i / firstNames.length) % lastNames.length]; + const role = roles[i % roles.length]; + const team = teams[i % teams.length]; + + return { + value: `${firstName.toLowerCase()}.${lastName.toLowerCase()}.${i}`, + label: `${firstName} ${lastName}`, + description: `${role} - ${team}`, + iconName: 'user-profile', + }; +}); + +const commandOptions: OptionDefinition[] = [ + { value: 'dev', label: 'Developer Mode', description: 'Optimized for code generation' }, + { value: 'creative', label: 'Creative Mode', description: 'Optimized for creative writing' }, + { value: 'analyze', label: 'Analyze Mode', description: 'Optimized for data analysis' }, + { value: 'summarize', label: 'Summarize Mode', description: 'Optimized for summarization' }, +]; + +const topicOptions: (OptionDefinition | OptionGroup)[] = [ + { value: 'aws', label: 'AWS', description: 'Amazon Web Services' }, + { + label: 'Cloudscape', + options: [ + { value: 'components', label: 'Components', description: 'UI components' }, + { value: 'design-tokens', label: 'Design Tokens', description: 'Design system tokens' }, + ], + }, + { value: 'react', label: 'React', description: 'JavaScript library' }, + { value: 'typescript', label: 'TypeScript', description: 'Typed JavaScript' }, + { value: 'accessibility', label: 'Accessibility', description: 'A11y best practices' }, + { value: 'performance', label: 'Performance', description: 'Optimization tips' }, +]; + +export default function PromptInputShortcutsPage() { + const [tokens, setTokens] = useState([]); + const [plainTextValue, setPlainTextValue] = useState(''); + const [files, setFiles] = useState([]); + const [extractedText, setExtractedText] = useState(''); + const [selectionStart, setSelectionStart] = useState('0'); + const [selectionEnd, setSelectionEnd] = useState('0'); + + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + + const { + isDisabled, + isReadOnly, + isInvalid, + hasWarning, + hasText, + hasSecondaryActions, + hasSecondaryContent, + hasPrimaryActions, + hasInfiniteMaxRows, + disableActionButton, + disableBrowserAutocorrect, + enableSpellcheck, + hasName, + enableAutoFocus, + } = urlParams; + + const [items, setItems] = React.useState([ + { label: 'Item 1', dismissLabel: 'Remove item 1', disabled: isDisabled }, + { label: 'Item 2', dismissLabel: 'Remove item 2', disabled: isDisabled }, + { label: 'Item 3', dismissLabel: 'Remove item 3', disabled: isDisabled }, + ]); + + // Define menus for shortcuts + const menus: PromptInputProps.MenuDefinition[] = [ + { + id: 'mentions', + trigger: '@', + options: mentionOptions, + filteringType: 'auto', + }, + { + id: 'mode', + trigger: '/', + options: commandOptions, + filteringType: 'auto', + useAtStart: true, + }, + { + id: 'topics', + trigger: '#', + options: topicOptions, + filteringType: 'auto', + }, + ]; + + useEffect(() => { + if (hasText) { + setTokens([{ type: 'text', value: placeholderText }]); + } + }, [hasText]); + + useEffect(() => { + if (plainTextValue !== placeholderText) { + setUrlParams({ hasText: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plainTextValue]); + + useEffect(() => { + if (items.length === 0) { + ref.current?.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]); + + useEffect(() => { + const newItems = items.map(item => ({ + label: item.label, + dismissLabel: item.dismissLabel, + disabled: isDisabled, + })); + setItems([...newItems]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDisabled]); + + const ref = React.createRef(); + + const buttonGroupRef = React.useRef(null); + + const onDismiss = (event: { detail: { fileIndex: number } }) => { + const newItems = [...files]; + newItems.splice(event.detail.fileIndex, 1); + setFiles(newItems); + }; + + return ( + +

PromptInput demo

+ + + setUrlParams({ isDisabled: !isDisabled })}> + Disabled + + setUrlParams({ isReadOnly: !isReadOnly })}> + Read-only + + setUrlParams({ isInvalid: !isInvalid })}> + Invalid + + setUrlParams({ hasWarning: !hasWarning })}> + Warning + + + setUrlParams({ + hasSecondaryContent: !hasSecondaryContent, + }) + } + > + Secondary content + + + setUrlParams({ + hasSecondaryActions: !hasSecondaryActions, + }) + } + > + Secondary actions + + + setUrlParams({ + hasPrimaryActions: !hasPrimaryActions, + }) + } + > + Custom primary actions + + + setUrlParams({ + hasInfiniteMaxRows: !hasInfiniteMaxRows, + }) + } + > + Infinite max rows + + + setUrlParams({ + disableActionButton: !disableActionButton, + }) + } + > + Disable action button + + + setUrlParams({ + disableBrowserAutocorrect: !disableBrowserAutocorrect, + }) + } + > + Disable browser autocorrect + + + setUrlParams({ + enableSpellcheck: !enableSpellcheck, + }) + } + > + Enable spellcheck + + + setUrlParams({ + hasName: !hasName, + }) + } + > + Has name attribute (for forms) + + + setUrlParams({ + enableAutoFocus: !enableAutoFocus, + }) + } + > + Enable auto focus + + + + + + + + + +
+ + + +
+ + {extractedText || tokens.length > 0 ? ( + {extractedText}, + }, + ] + : []), + ...(tokens.length > 0 + ? [ + { + label: 'Current tokens', + value: {JSON.stringify(tokens, null, 2)}, + }, + ] + : []), + ]} + /> + ) : null} + +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + console.log('FORM SUBMITTED (fallback):', { + 'user-prompt': formData.get('user-prompt'), + }); + }} + > + + MAX_CHARS || isInvalid) && 'The query has too many characters.'} + warningText={hasWarning && 'This input has a warning'} + constraintText={ + <> + This service is subject to some policy. Character count: {plainTextValue.length}/{MAX_CHARS} + + } + i18nStrings={{ errorIconAriaLabel: 'Error' }} + > + { + setTokens(event.detail.tokens); + setPlainTextValue(event.detail.value ?? ''); + }} + onAction={({ detail }) => { + setExtractedText(detail.value ?? ''); + + // Keep mode token (first pinned reference from useAtStart menu) after submission + const modeToken = detail.tokens.find( + (token): token is PromptInputProps.ReferenceToken => + token.type === 'reference' && token.pinned === true + ); + + setTokens(modeToken ? [modeToken] : []); + setPlainTextValue(''); + + window.alert( + `Submitted:\n\nPlain text: ${detail.value ?? ''}\n\nTokens: ${JSON.stringify( + detail.tokens, + null, + 2 + )}` + ); + }} + placeholder="Ask a question" + maxRows={hasInfiniteMaxRows ? -1 : 4} + disabled={isDisabled} + readOnly={isReadOnly} + invalid={isInvalid || plainTextValue.length > MAX_CHARS} + warning={hasWarning} + ref={ref} + disableSecondaryActionsPaddings={true} + disableActionButton={disableActionButton} + disableBrowserAutocorrect={disableBrowserAutocorrect} + spellcheck={enableSpellcheck} + name={hasName ? 'user-prompt' : undefined} + autoFocus={enableAutoFocus} + menus={menus} + onMenuItemSelect={event => { + console.log('Menu selection:', event.detail); + // Modes are now just reference tokens - no special handling needed + }} + i18nStrings={ + { + selectedMenuItemAriaLabel: 'Selected', + menuErrorIconAriaLabel: 'Error', + menuRecoveryText: 'Retry', + tokenInsertedAriaLabel: (token: { label?: string; value: string }) => + `${token.label || token.value} inserted`, + tokenPinnedAriaLabel: (token: { label?: string; value: string }) => + `${token.label || token.value} pinned`, + tokenRemovedAriaLabel: (token: { label?: string; value: string }) => + `${token.label || token.value} removed`, + } as PromptInputProps['i18nStrings'] + } + customPrimaryAction={ + hasPrimaryActions ? ( + + ) : undefined + } + secondaryActions={ + hasSecondaryActions ? ( + + detail.id.includes('files') && setFiles(detail.files)} + onItemClick={({ detail }) => { + if (detail.id === 'slash') { + ref.current?.insertText('/', 0); + } + if (detail.id === 'at') { + ref.current?.insertText('@'); + } + }} + items={[ + { + type: 'icon-file-input', + id: 'files', + text: 'Upload files', + multiple: true, + }, + { + type: 'icon-button', + id: 'expand', + iconName: 'expand', + text: 'Go full page', + disabled: isDisabled || isReadOnly, + }, + { + type: 'icon-button', + id: 'remove', + iconName: 'remove', + text: 'Remove', + disabled: isDisabled || isReadOnly, + }, + { + type: 'icon-button', + id: 'slash', + iconName: 'slash', + text: 'Insert slash', + disabled: isDisabled || isReadOnly, + }, + { + type: 'icon-button', + id: 'at', + iconName: 'at-symbol', + text: 'Insert at symbol', + disabled: isDisabled || isReadOnly, + }, + ]} + variant="icon" + /> + + ) : undefined + } + secondaryContent={ + hasSecondaryContent && files.length > 0 ? ( + ({ + file, + }))} + showFileThumbnail={true} + onDismiss={onDismiss} + i18nStrings={i18nStrings} + alignment="horizontal" + /> + ) : undefined + } + /> + +
+ + + +
+ } + /> + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b7c0e3b169..170a9a3787 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -19541,10 +19541,17 @@ exports[`Components definition for prompt-input matches the snapshot: prompt-inp { "cancelable": false, "description": "Called whenever a user clicks the action button or presses the "Enter" key. -The event \`detail\` contains the current value of the field.", +The event \`detail\` contains the current value as a string and an array of tokens. + +When \`menus\` is defined, the \`value\` is derived from \`tokensToText(tokens)\` if provided, otherwise from the default token-to-text conversion.", "detailInlineType": { - "name": "BaseChangeDetail", + "name": "PromptInputProps.ActionDetail", "properties": [ + { + "name": "tokens", + "optional": false, + "type": "Array", + }, { "name": "value", "optional": false, @@ -19553,7 +19560,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "PromptInputProps.ActionDetail", "name": "onAction", }, { @@ -19564,10 +19571,17 @@ The event \`detail\` contains the current value of the field.", { "cancelable": false, "description": "Called whenever a user changes the input value (by typing or pasting). -The event \`detail\` contains the current value of the field.", +The event \`detail\` contains the current value as a string and an array of tokens. + +When \`menus\` is defined, the \`value\` is derived from \`tokensToText(tokens)\` if provided, otherwise from the default token-to-text conversion.", "detailInlineType": { - "name": "BaseChangeDetail", + "name": "PromptInputProps.ChangeDetail", "properties": [ + { + "name": "tokens", + "optional": false, + "type": "Array", + }, { "name": "value", "optional": false, @@ -19576,7 +19590,7 @@ The event \`detail\` contains the current value of the field.", ], "type": "object", }, - "detailType": "BaseChangeDetail", + "detailType": "PromptInputProps.ChangeDetail", "name": "onChange", }, { @@ -19682,6 +19696,326 @@ about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", "detailType": "BaseKeyDetail", "name": "onKeyUp", }, + { + "cancelable": false, + "description": "Called when the user types to filter options in manual filtering mode for a menu. +Use this to filter the options based on the filtering text. + +The detail object contains: +- \`menuId\` - The ID of the menu that triggered the event. +- \`filteringText\` - The text to use for filtering options.", + "detailInlineType": { + "name": "PromptInputProps.MenuFilterDetail", + "properties": [ + { + "name": "filteringText", + "optional": false, + "type": "string", + }, + { + "name": "menuId", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuFilterDetail", + "name": "onMenuFilter", + }, + { + "cancelable": false, + "description": "Called whenever a user selects an option in a menu.", + "detailInlineType": { + "name": "PromptInputProps.MenuItemSelectDetail", + "properties": [ + { + "name": "menuId", + "optional": false, + "type": "string", + }, + { + "inlineType": { + "name": "OptionDefinition", + "properties": [ + { + "name": "__labelPrefix", + "optional": true, + "type": "string", + }, + { + "name": "description", + "optional": true, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "name": "disabledReason", + "optional": true, + "type": "string", + }, + { + "name": "filteringTags", + "optional": true, + "type": "ReadonlyArray", + }, + { + "name": "iconAlt", + "optional": true, + "type": "string", + }, + { + "name": "iconAriaLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "IconProps.Name", + "type": "union", + "values": [ + "search", + "map", + "filter", + "key", + "file", + "pause", + "play", + "microphone", + "remove", + "copy", + "menu", + "script", + "close", + "status-pending", + "refresh", + "external", + "history", + "group", + "calendar", + "ellipsis", + "zoom-in", + "zoom-out", + "security", + "download", + "edit", + "add-plus", + "anchor-link", + "angle-left-double", + "angle-left", + "angle-right-double", + "angle-right", + "angle-up", + "angle-down", + "arrow-left", + "arrow-right", + "arrow-up", + "arrow-down", + "at-symbol", + "audio-full", + "audio-half", + "audio-off", + "backward-10-seconds", + "bug", + "call", + "caret-down-filled", + "caret-down", + "caret-left-filled", + "caret-right-filled", + "caret-up-filled", + "caret-up", + "check", + "contact", + "closed-caption", + "closed-caption-unavailable", + "command-prompt", + "delete-marker", + "drag-indicator", + "edit-gen-ai", + "envelope", + "exit-full-screen", + "expand", + "face-happy", + "face-happy-filled", + "face-neutral", + "face-neutral-filled", + "face-sad", + "face-sad-filled", + "file-open", + "flag", + "folder-open", + "folder", + "forward-10-seconds", + "full-screen", + "gen-ai", + "globe", + "grid-view", + "group-active", + "heart", + "heart-filled", + "insert-row", + "keyboard", + "list-view", + "location-pin", + "lock-private", + "microphone-off", + "mini-player", + "multiscreen", + "notification", + "redo", + "resize-area", + "search-gen-ai", + "settings", + "send", + "share", + "shrink", + "slash", + "star-filled", + "star-half", + "star", + "status-in-progress", + "status-info", + "status-negative", + "status-not-started", + "status-positive", + "status-stopped", + "status-warning", + "stop-circle", + "subtract-minus", + "suggestions", + "suggestions-gen-ai", + "support", + "thumbs-down-filled", + "thumbs-down", + "thumbs-up-filled", + "thumbs-up", + "ticket", + "transcript", + "treeview-collapse", + "treeview-expand", + "undo", + "unlocked", + "upload-download", + "upload", + "user-profile-active", + "user-profile", + "video-off", + "video-on", + "video-unavailable", + "video-camera-off", + "video-camera-on", + "video-camera-unavailable", + "view-full", + "view-horizontal", + "view-vertical", + "zoom-to-fit", + ], + }, + "name": "iconName", + "optional": true, + "type": "string", + }, + { + "name": "iconSvg", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "iconUrl", + "optional": true, + "type": "string", + }, + { + "name": "label", + "optional": true, + "type": "string", + }, + { + "name": "labelContent", + "optional": true, + "type": "React.ReactNode", + }, + { + "name": "labelTag", + "optional": true, + "type": "string", + }, + { + "name": "lang", + "optional": true, + "type": "string", + }, + { + "name": "tags", + "optional": true, + "type": "ReadonlyArray", + }, + { + "name": "value", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "option", + "optional": false, + "type": "OptionDefinition", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuItemSelectDetail", + "name": "onMenuItemSelect", + }, + { + "cancelable": false, + "description": "Use this event to implement the asynchronous behavior for menus. + +The event is called in the following situations: +- The user scrolls to the end of the list of options, if \`statusType\` is set to \`pending\` (pagination). +- The user clicks on the recovery button in the error state. +- The user types after the trigger character. +- The menu is opened. + +The detail object contains the following properties: +- \`menuId\` - The ID of the menu that triggered the event. +- \`filteringText\` - The value to use to fetch options (undefined for pagination). +- \`firstPage\` - Indicates that you should fetch the first page of options. +- \`samePage\` - Indicates that you should fetch the same page (for example, when clicking recovery button).", + "detailInlineType": { + "name": "PromptInputProps.MenuLoadItemsDetail", + "properties": [ + { + "name": "filteringText", + "optional": true, + "type": "string", + }, + { + "name": "firstPage", + "optional": false, + "type": "boolean", + }, + { + "name": "menuId", + "optional": false, + "type": "string", + }, + { + "name": "samePage", + "optional": false, + "type": "boolean", + }, + ], + "type": "object", + }, + "detailType": "PromptInputProps.MenuLoadItemsDetail", + "name": "onMenuLoadItems", + }, ], "functions": [ { @@ -19690,6 +20024,25 @@ about modifiers (that is, CTRL, ALT, SHIFT, META, etc.).", "parameters": [], "returnType": "void", }, + { + "description": "Inserts text at a specified position. Triggers input events and menu detection when \`menus\` is defined.", + "name": "insertText", + "parameters": [ + { + "name": "text", + "type": "string", + }, + { + "name": "cursorStart", + "type": "number", + }, + { + "name": "cursorEnd", + "type": "number", + }, + ], + "returnType": "void", + }, { "description": "Selects all text in the textarea control.", "name": "select", @@ -19723,6 +20076,7 @@ common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-set "name": "PromptInput", "properties": [ { + "deprecatedTag": "Use \`i18nStrings.actionButtonAriaLabel\` instead.", "description": "Adds an aria-label to the action button.", "i18nTag": true, "name": "actionButtonAriaLabel", @@ -19936,7 +20290,9 @@ In some cases it might be appropriate to disable autocomplete (for example, for To use it correctly, set the \`name\` property. You can either provide a boolean value to set the property to "on" or "off", or specify a string value -for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute.", +for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute. + +Note: When \`menus\` is defined, autocomplete will not function.", "inlineType": { "name": "string | boolean", "type": "union", @@ -20014,6 +20370,63 @@ receive focus.", "optional": true, "type": "boolean", }, + { + "description": "An object containing all the localized strings required by the component. + +- \`ariaLabel\` (string) - Adds an aria-label to the input element. +- \`actionButtonAriaLabel\` (string) - Adds an aria-label to the action button. +- \`menuErrorIconAriaLabel\` (string) - Provides a text alternative for the error icon in the error message in menus. +- \`menuRecoveryText\` (string) - Specifies the text for the recovery button in menus. The text is displayed next to the error text. +- \`menuLoadingText\` (string) - Specifies the text to display when menus are in a loading state. +- \`menuFinishedText\` (string) - Specifies the text to display when menus have finished loading all items. +- \`menuErrorText\` (string) - Specifies the text to display when menus encounter an error while loading. +- \`selectedMenuItemAriaLabel\` (string) - Specifies the localized string that describes an option as being selected.", + "i18nTag": true, + "inlineType": { + "name": "PromptInputProps.I18nStrings", + "properties": [ + { + "name": "actionButtonAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "menuErrorIconAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "menuErrorText", + "optional": true, + "type": "string", + }, + { + "name": "menuFinishedText", + "optional": true, + "type": "string", + }, + { + "name": "menuLoadingText", + "optional": true, + "type": "string", + }, + { + "name": "menuRecoveryText", + "optional": true, + "type": "string", + }, + { + "name": "selectedMenuItemAriaLabel", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "PromptInputProps.I18nStrings", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -20033,6 +20446,13 @@ single form field.", "optional": true, "type": "boolean", }, + { + "description": "Maximum height of the menu dropdown in pixels. +When not specified, the menu will grow to fit its content.", + "name": "maxMenuHeight", + "optional": true, + "type": "number", + }, { "defaultValue": "3", "description": "Specifies the maximum number of lines of text the textarea will expand to. @@ -20041,6 +20461,13 @@ Defaults to 3. Use -1 for infinite rows.", "optional": true, "type": "number", }, + { + "description": "Menus that can be triggered via specific symbols (e.g., "/" or "@"). +For menus only relevant to triggers at the start of the input, set \`useAtStart: true\`, defaults to \`false\`.", + "name": "menus", + "optional": true, + "type": "Array", + }, { "defaultValue": "1", "description": "Specifies the minimum number of lines of text to set the height to.", @@ -20049,7 +20476,7 @@ Defaults to 3. Use -1 for infinite rows.", "type": "number", }, { - "description": "Specifies the name of the control used in HTML forms.", + "description": "Specifies the name of the prompt input for form submissions.", "name": "name", "optional": true, "type": "string", @@ -20060,7 +20487,8 @@ Some attributes will be automatically combined with internal attribute values: - \`className\` will be appended. - Event handlers will be chained, unless the default is prevented. -We do not support using this attribute to apply custom styling.", +We do not support using this attribute to apply custom styling. +If \`tokens\` is defined, nativeTextareaAttributes will be ignored.", "inlineType": { "name": "Omit, "children"> & Record<\`data-\${string}\`, string>", "type": "union", @@ -20092,6 +20520,35 @@ Don't use read-only inputs outside a form.", "optional": true, "type": "boolean", }, + { + "description": "Overrides the element that is announced to screen readers in menus +when the highlighted option changes. By default, this announces +the option's name and properties, and its selected state if +the \`selectedLabel\` property is defined. +The highlighted option is provided, and its group (if groups +are used and it differs from the group of the previously highlighted option). + +For more information, see the +[accessibility guidelines](/components/prompt-input/?tabId=usage#accessibility-guidelines).", + "inlineType": { + "name": "AutosuggestProps.ContainingOptionAndGroupString", + "parameters": [ + { + "name": "option", + "type": "OptionDefinition", + }, + { + "name": "group", + "type": "AutosuggestProps.OptionGroup", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "renderHighlightedMenuItemAriaLive", + "optional": true, + "type": "AutosuggestProps.ContainingOptionAndGroupString", + }, { "description": "Specifies the value of the \`spellcheck\` attribute on the native control. This value controls the native browser capability to check for spelling/grammar errors. @@ -20106,8 +20563,6 @@ inadvertently sending data (such as user passwords) to third parties.", "type": "boolean", }, { - "description": "An object containing CSS properties to customize the prompt input's visual appearance. -Refer to the [style](/components/prompt-input/?tabId=style) tab for more details.", "inlineType": { "name": "PromptInputProps.Style", "properties": [ @@ -20338,9 +20793,58 @@ Refer to the [style](/components/prompt-input/?tabId=style) tab for more details "type": "PromptInputProps.Style", }, { - "description": "Specifies the text entered into the form element.", + "description": "Specifies the content of the prompt input when using token mode. + +All tokens use the same unified structure with a \`value\` property: +- Text tokens: \`value\` contains the text content +- Reference tokens: \`value\` contains the reference value, \`label\` for display (e.g., '@john') +- Trigger tokens: \`value\` contains the filter text, \`triggerChar\` for the trigger character + +When \`menus\` is defined, you should use \`tokens\` to control the content instead of \`value\`.", + "name": "tokens", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "Custom function to transform tokens into plain text for the \`value\` field in \`onChange\` and \`onAction\` events +and for the hidden input when \`name\` is specified. + +If not provided, the default implementation is: +\`\`\` +tokens.map(token => token.value).join(''); +\`\`\` + +Use this to customize serialization, for example: +- Using \`label\` instead of \`value\` for reference tokens +- Adding custom formatting or separators between tokens", + "inlineType": { + "name": "(tokens: ReadonlyArray) => string", + "parameters": [ + { + "name": "tokens", + "type": "ReadonlyArray", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokensToText", + "optional": true, + "type": "((tokens: ReadonlyArray) => string)", + }, + { + "description": "Specifies the content of the prompt input. + +When \`menus\` is defined (token mode): +- This property is optional and defaults to empty string +- The actual content is managed via the \`tokens\` array +- \`onChange\` and \`onAction\` events will provide the serialized text value + +When \`menus\` is not defined (text mode): +- This property is required +- Represents the current text content of the textarea", "name": "value", - "optional": false, + "optional": true, "type": "string", }, { @@ -39367,6 +39871,21 @@ If not specified, the method returns the result text that is currently displayed ], }, }, + { + "description": "Finds the contentEditable element used when menus are defined. +Returns null if the component does not have menus defined.", + "name": "findContentEditableElement", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLDivElement", + }, + ], + }, + }, { "name": "findCustomPrimaryAction", "parameters": [], @@ -39381,6 +39900,20 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "description": "Finds the menu dropdown (always in portal due to expandToViewport=true).", + "name": "findMenu", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "PromptInputMenuWrapper", + }, + }, + { + "description": "Finds the native textarea element. + +Note: When menus are defined, the component uses a contentEditable element instead of a textarea. +In this case, this method may fail to find the textarea element. Use findContentEditableElement() +or the getValue()/setValue() methods instead.", "name": "findNativeTextarea", "parameters": [], "returnType": { @@ -39420,11 +39953,19 @@ If not specified, the method returns the result text that is currently displayed ], }, }, + { + "name": "getTextareaValue", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "string", + }, + }, { "description": "Gets the value of the component. -Returns the current value of the textarea.", - "name": "getTextareaValue", +Returns the current value of the textarea (when no menus are defined) or the text content of the contentEditable element (when menus are defined).", + "name": "getValue", "parameters": [], "returnType": { "isNullable": false, @@ -39432,7 +39973,51 @@ Returns the current value of the textarea.", }, }, { - "description": "Sets the value of the component and calls the onChange handler.", + "description": "Checks if the menu is currently open.", + "name": "isMenuOpen", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "boolean", + }, + }, + { + "description": "Selects an option from the menu by simulating mouse events.", + "name": "selectMenuOption", + "parameters": [ + { + "description": "1-based index of the option to select", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { + "description": "Selects an option from the menu by simulating mouse events.", + "name": "selectMenuOptionByValue", + "parameters": [ + { + "description": "value of option to select", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + { "name": "setTextareaValue", "parameters": [ { @@ -39449,9 +40034,83 @@ Returns the current value of the textarea.", "name": "void", }, }, + { + "description": "Sets the value of the component by directly setting text content. +This does NOT trigger menu detection. Use the component ref's insertText() method +to simulate typing and trigger menus.", + "name": "setValue", + "parameters": [ + { + "description": "String value to set the component to.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, ], "name": "PromptInputWrapper", }, + { + "methods": [ + { + "description": "Returns an option from the menu.", + "name": "findOption", + "parameters": [ + { + "description": "1-based index of the option to select.", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionWrapper", + }, + }, + { + "description": "Returns an option from the menu by its value", + "name": "findOptionByValue", + "parameters": [ + { + "description": "The 'value' of the option.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "OptionWrapper", + }, + }, + { + "name": "findOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "OptionWrapper", + }, + ], + }, + }, + ], + "name": "PromptInputMenuWrapper", + }, { "methods": [ { @@ -48653,6 +49312,16 @@ If not specified, the method returns the result text that is currently displayed "name": "ElementWrapper", }, }, + { + "description": "Finds the contentEditable element used when menus are defined. +Returns null if the component does not have menus defined.", + "name": "findContentEditableElement", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findCustomPrimaryAction", "parameters": [], @@ -48662,6 +49331,20 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "description": "Finds the menu dropdown (always in portal due to expandToViewport=true).", + "name": "findMenu", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "PromptInputMenuWrapper", + }, + }, + { + "description": "Finds the native textarea element. + +Note: When menus are defined, the component uses a contentEditable element instead of a textarea. +In this case, this method may fail to find the textarea element. Use findContentEditableElement() +or the getValue()/setValue() methods instead.", "name": "findNativeTextarea", "parameters": [], "returnType": { @@ -48689,6 +49372,60 @@ If not specified, the method returns the result text that is currently displayed ], "name": "PromptInputWrapper", }, + { + "methods": [ + { + "description": "Returns an option from the menu.", + "name": "findOption", + "parameters": [ + { + "description": "1-based index of the option to select.", + "flags": { + "isOptional": false, + }, + "name": "optionIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionWrapper", + }, + }, + { + "description": "Returns an option from the menu by its value", + "name": "findOptionByValue", + "parameters": [ + { + "description": "The 'value' of the option.", + "flags": { + "isOptional": false, + }, + "name": "value", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "OptionWrapper", + }, + }, + { + "name": "findOptions", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "OptionWrapper", + }, + ], + }, + }, + ], + "name": "PromptInputMenuWrapper", + }, { "methods": [ { diff --git a/src/internal/components/dropdown/dropdown-fit-handler.ts b/src/internal/components/dropdown/dropdown-fit-handler.ts index 7b95f2f265..1d3ad1ef34 100644 --- a/src/internal/components/dropdown/dropdown-fit-handler.ts +++ b/src/internal/components/dropdown/dropdown-fit-handler.ts @@ -358,7 +358,7 @@ const getInteriorDropdownPosition = ( export const calculatePosition = ( dropdownElement: HTMLDivElement, - triggerElement: HTMLDivElement, + triggerElement: HTMLElement, verticalContainerElement: HTMLDivElement, interior: boolean, expandToViewport: boolean, diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index fa87bdf160..11f2fa13f2 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -170,6 +170,7 @@ const TransitionContent = ({ const Dropdown = ({ content, trigger, + triggerRef: externalTriggerRef, open, onOutsideClick, onMouseDown, @@ -180,6 +181,7 @@ const Dropdown = ({ stretchHeight = false, minWidth, maxWidth, + maxHeight, hideBlockBorder = true, expandToViewport = false, preferredAlignment = 'start', @@ -199,13 +201,16 @@ const Dropdown = ({ ariaDescribedby, }: DropdownProps) => { const wrapperRef = useRef(null); - const triggerRef = useRef(null); + const internalTriggerRef = useRef(null); const dropdownRef = useRef(null); const dropdownContainerRef = useRef(null); const verticalContainerRef = useRef(null); // To keep track of the initial position (drop up/down) which is kept the same during fixed repositioning const fixedPosition = useRef(null); + // Use external trigger ref if provided, otherwise use internal ref + const triggerRef = externalTriggerRef || internalTriggerRef; + const isRefresh = useVisualRefresh(); const dropdownClasses = usePortalModeClasses(triggerRef); @@ -226,7 +231,9 @@ const Dropdown = ({ target: HTMLDivElement, verticalContainer: HTMLDivElement ) => { - verticalContainer.style.maxBlockSize = position.blockSize; + // Apply maxBlockSize, constrained by maxHeight prop if provided + const constrainedBlockSize = maxHeight ? `min(${position.blockSize}, ${maxHeight}px)` : position.blockSize; + verticalContainer.style.maxBlockSize = constrainedBlockSize; // Only apply occupy-entire-width when matching trigger width exactly and not in portal mode if (!interior && matchTriggerWidth && !expandToViewport) { @@ -411,7 +418,7 @@ const Dropdown = ({ return () => { window.removeEventListener('click', clickListener, true); }; - }, [open, onOutsideClick]); + }, [open, onOutsideClick, triggerRef]); // subscribe to Escape key press useEffect(() => { @@ -457,7 +464,7 @@ const Dropdown = ({ return () => { controller.abort(); }; - }, [open, expandToViewport, isMobile]); + }, [open, expandToViewport, isMobile, triggerRef]); const referrerId = useUniqueId(); @@ -496,9 +503,15 @@ const Dropdown = ({ onFocus={focusHandler} onBlur={blurHandler} > -
- {trigger} -
+ {!externalTriggerRef && ( +
+ {trigger} +
+ )} dropdownRef.current && getFirstFocusable(dropdownRef.current)?.focus()} diff --git a/src/internal/components/dropdown/interfaces.ts b/src/internal/components/dropdown/interfaces.ts index f47757f6a9..b7146e8c72 100644 --- a/src/internal/components/dropdown/interfaces.ts +++ b/src/internal/components/dropdown/interfaces.ts @@ -63,6 +63,11 @@ export interface DropdownProps extends ExpandToViewport { */ trigger: React.ReactNode; + /** + * Optional ref to an element that should be used for positioning calculations. + */ + triggerRef?: React.RefObject; + /** * "Sticky" header of the dropdown content */ @@ -131,6 +136,12 @@ export interface DropdownProps extends ExpandToViewport { */ maxWidth?: DropdownWidthConstraint; + /** + * Maximum height constraint for the dropdown content in pixels. + * When set, constrains the calculated height to not exceed this value. + */ + maxHeight?: number; + /** * Preferred alignment of the dropdown relative to its trigger. * The dropdown will attempt this alignment first, but will automatically diff --git a/src/prompt-input/__tests__/prompt-input.test.tsx b/src/prompt-input/__tests__/prompt-input.test.tsx index 92be6a6873..7f084ea517 100644 --- a/src/prompt-input/__tests__/prompt-input.test.tsx +++ b/src/prompt-input/__tests__/prompt-input.test.tsx @@ -274,7 +274,7 @@ describe('events', () => { wrapper.setTextareaValue('updated value'); - expect(onChange).toHaveBeenCalledWith({ value: 'updated value' }); + expect(onChange).toHaveBeenCalledWith({ value: 'updated value', tokens: [] }); }); test('fire an action event on action button click with correct parameters', () => { diff --git a/src/prompt-input/components/menu-dropdown.tsx b/src/prompt-input/components/menu-dropdown.tsx new file mode 100644 index 0000000000..c0d794b3c9 --- /dev/null +++ b/src/prompt-input/components/menu-dropdown.tsx @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import PlainList from '../../autosuggest/plain-list'; +import VirtualList from '../../autosuggest/virtual-list'; +import { MenuItemsHandlers, MenuItemsState } from '../core/menu-state'; +import { PromptInputProps } from '../interfaces'; + +interface MenuDropdownProps { + menu: PromptInputProps.MenuDefinition; + statusType: PromptInputProps.MenuDefinition['statusType']; + menuItemsState: MenuItemsState; + menuItemsHandlers: MenuItemsHandlers; + highlightedOptionId?: string; + highlightText: string; + listId: string; + controlId: string; + handleLoadMore: () => void; + hasDropdownStatus?: boolean; + listBottom?: React.ReactNode; + ariaDescribedby?: string; +} + +const createMouseEventHandler = (handler: (index: number) => void) => (itemIndex: number) => { + if (itemIndex > -1) { + handler(itemIndex); + } +}; + +export default function MenuDropdown({ + menu, + statusType, + menuItemsState, + menuItemsHandlers, + highlightedOptionId, + highlightText, + listId, + controlId, + handleLoadMore, + hasDropdownStatus, + listBottom, + ariaDescribedby, +}: MenuDropdownProps) { + const handleMouseUp = createMouseEventHandler(menuItemsHandlers.selectVisibleOptionWithMouse); + const handleMouseMove = createMouseEventHandler(menuItemsHandlers.highlightVisibleOptionWithMouse); + + const ListComponent = menu.virtualScroll ? VirtualList : PlainList; + + return ( + + ); +} diff --git a/src/prompt-input/components/textarea-mode.tsx b/src/prompt-input/components/textarea-mode.tsx new file mode 100644 index 0000000000..9835afdd6d --- /dev/null +++ b/src/prompt-input/components/textarea-mode.tsx @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import WithNativeAttributes from '../../internal/utils/with-native-attributes'; + +interface TextareaModeProps { + textareaRef: React.RefObject; + controlId?: string; + textareaAttributes: React.TextareaHTMLAttributes; + nativeTextareaAttributes?: Record; +} + +export default function TextareaMode({ + textareaRef, + controlId, + textareaAttributes, + nativeTextareaAttributes, +}: TextareaModeProps) { + return ( + + ); +} diff --git a/src/prompt-input/components/token-mode.tsx b/src/prompt-input/components/token-mode.tsx new file mode 100644 index 0000000000..49ccb60cfa --- /dev/null +++ b/src/prompt-input/components/token-mode.tsx @@ -0,0 +1,165 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import Dropdown from '../../internal/components/dropdown'; +import DropdownFooter from '../../internal/components/dropdown-footer'; +import { MenuItemsHandlers, MenuItemsState } from '../core/menu-state'; +import { PromptInputProps } from '../interfaces'; +import MenuDropdown from './menu-dropdown'; + +import styles from '../styles.css.js'; +import testutilStyles from '../test-classes/styles.css.js'; + +interface TokenModeProps { + // Refs + editableElementRef: React.RefObject; + triggerWrapperRef: React.MutableRefObject; + + // IDs + controlId?: string; + menuListId: string; + menuFooterControlId: string; + highlightedMenuOptionId?: string; + + // State + name?: string; + getPlainTextValue: () => string; + menuIsOpen: boolean; + triggerWrapperReady: boolean; + shouldRenderMenuDropdown: boolean; + + // Menu data + activeMenu: PromptInputProps.MenuDefinition | null; + activeTriggerToken: PromptInputProps.TriggerToken | null; + menuFilterText: string; + menuItemsState: MenuItemsState | null; + menuItemsHandlers: MenuItemsHandlers | null; + menuDropdownStatus: any; + + // Handlers + handleInput: () => void; + handleLoadMore: () => void; + + // Attributes + editableElementAttributes: React.HTMLAttributes & { + 'data-placeholder'?: string; + }; + + // i18n + i18nStrings?: PromptInputProps['i18nStrings']; + + maxMenuHeight?: number; +} + +const MENU_MIN_WIDTH = 300; + +export default function TokenMode({ + editableElementRef, + triggerWrapperRef, + controlId, + menuListId, + menuFooterControlId, + highlightedMenuOptionId, + name, + getPlainTextValue, + menuIsOpen, + triggerWrapperReady, + shouldRenderMenuDropdown, + activeMenu, + activeTriggerToken, + menuFilterText, + menuItemsState, + menuItemsHandlers, + menuDropdownStatus, + maxMenuHeight, + handleInput, + handleLoadMore, + editableElementAttributes, +}: TokenModeProps) { + return ( + <> + {name && } +
+
+ 0 + ) + } + trigger={null} + triggerRef={triggerWrapperRef} + contentKey={ + triggerWrapperReady + ? `trigger-${activeTriggerToken?.id}-${activeTriggerToken?.triggerChar}-${menuFilterText}` + : undefined + } + onMouseDown={event => { + event.preventDefault(); + }} + footer={ + menuDropdownStatus?.isSticky && menuDropdownStatus.content ? ( + = 1 : false} + /> + ) : null + } + content={ + <> + {shouldRenderMenuDropdown && menuItemsState && menuItemsHandlers && activeMenu && ( + + ) : null + } + ariaDescribedby={menuDropdownStatus?.content ? menuFooterControlId : undefined} + /> + )} + + } + /> +
+ + ); +} diff --git a/src/prompt-input/core/constants.ts b/src/prompt-input/core/constants.ts new file mode 100644 index 0000000000..0810157769 --- /dev/null +++ b/src/prompt-input/core/constants.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const ELEMENT_TYPES = { + REFERENCE: 'reference', + PINNED: 'pinned', + CURSOR_SPOT_BEFORE: 'cursor-spot-before', + CURSOR_SPOT_AFTER: 'cursor-spot-after', + TRIGGER: 'trigger', + TRAILING_BREAK: 'trailing-break', +}; + +export const SPECIAL_CHARS = { + ZWNJ: '\u200B', + NEWLINE: '\n', +}; + +export const DEFAULT_MAX_ROWS = 3; +export const NEXT_TICK_TIMEOUT = 0; +export const CURSOR_DETECTION_DELAY = 100; diff --git a/src/prompt-input/core/cursor-manager.ts b/src/prompt-input/core/cursor-manager.ts new file mode 100644 index 0000000000..51ac7daf8e --- /dev/null +++ b/src/prompt-input/core/cursor-manager.ts @@ -0,0 +1,350 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PromptInputProps } from '../interfaces'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { isBreakToken, isHTMLElement, isTextNode, isTextToken } from './type-guards'; +import { findAllParagraphs, getTokenType } from './utils'; + +// HELPER FUNCTIONS + +function isReferenceTokenType(tokenType: string | null): boolean { + return tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED; +} + +/** + * Gets the length of a token element in the DOM. + * - Text nodes: their text length + * - Trigger tokens: full text length (including trigger char, e.g., "@bob" = 4) + * - Reference/pinned tokens: 1 (atomic) + */ +function getTokenElementLength(child: Node): number { + if (isTextNode(child)) { + return child.textContent?.length || 0; + } + + if (isHTMLElement(child)) { + const tokenType = getTokenType(child); + if (tokenType === ELEMENT_TYPES.TRIGGER) { + return child.textContent?.length || 0; + } + if (isReferenceTokenType(tokenType)) { + return 1; + } + } + + return 0; +} + +// BASIC CURSOR POSITIONING + +/** + * Generic function to position cursor using a range configuration callback. + */ +function positionCursor(configureRange: (range: Range) => void): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + configureRange(range); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} + +export function positionBefore(node: Node): void { + positionCursor(range => range.setStartBefore(node)); +} + +export function positionAfter(node: Node): void { + positionCursor(range => range.setStartAfter(node)); +} + +export function positionAtStartOfParagraph(paragraph: HTMLElement): void { + positionCursor(range => range.setStart(paragraph, 0)); +} + +function positionCursorAtOffset(node: Node, offset: number): void { + positionCursor(range => range.setStart(node, offset)); +} + +// POSITION CALCULATION + +function countParagraphContent(p: Element): number { + let count = 0; + for (const child of Array.from(p.childNodes)) { + count += getTokenElementLength(child); + } + return count; +} + +function countUpToCursor(p: Element, range: Range): number { + let count = 0; + + // Special case: cursor is at paragraph level (between child nodes) + if (range.startContainer === p) { + for (let i = 0; i < range.startOffset && i < p.childNodes.length; i++) { + count += getTokenElementLength(p.childNodes[i]); + } + return count; + } + + for (const child of Array.from(p.childNodes)) { + const childContainsCursor = child === range.startContainer || child.contains(range.startContainer); + + if (childContainsCursor) { + if (isTextNode(child)) { + count += range.startOffset; + } else if (isHTMLElement(child)) { + const tokenType = getTokenType(child); + + if (tokenType === ELEMENT_TYPES.TRIGGER) { + const triggerTextNode = child.childNodes[0]; + if (triggerTextNode && isTextNode(triggerTextNode) && triggerTextNode === range.startContainer) { + count += range.startOffset; + } + } else if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { + const cursorSpotBefore = child.querySelector(`[data-type="${ELEMENT_TYPES.CURSOR_SPOT_BEFORE}"]`); + const cursorSpotAfter = child.querySelector(`[data-type="${ELEMENT_TYPES.CURSOR_SPOT_AFTER}"]`); + + const cursorInBefore = + cursorSpotBefore && + (cursorSpotBefore === range.startContainer || cursorSpotBefore.contains(range.startContainer)); + const cursorInAfter = + cursorSpotAfter && + (cursorSpotAfter === range.startContainer || cursorSpotAfter.contains(range.startContainer)); + + if (cursorInBefore) { + const beforeContent = (cursorSpotBefore!.textContent || '').replace( + new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), + '' + ); + if (beforeContent && isTextNode(range.startContainer)) { + count += range.startOffset; + } + } else if (cursorInAfter) { + count += 1; + + const afterContent = (cursorSpotAfter!.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (afterContent && isTextNode(range.startContainer)) { + const contentOffset = Math.max(0, range.startOffset - 1); + count += contentOffset; + } + } else { + count += 1; + } + } + } + break; + } + + count += getTokenElementLength(child); + } + + return count; +} + +export function getCursorPosition(element: HTMLElement): number { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return 0; + } + + const range = selection.getRangeAt(0); + + if (!element.contains(range.startContainer)) { + return 0; + } + + const paragraphs = findAllParagraphs(element); + + if (paragraphs.length === 0) { + return 0; + } + + let position = 0; + + for (let pIndex = 0; pIndex < paragraphs.length; pIndex++) { + const p = paragraphs[pIndex]; + + if (pIndex > 0) { + position += 1; // Line break + } + + if (!p.contains(range.startContainer)) { + position += countParagraphContent(p); + } else { + position += countUpToCursor(p, range); + break; + } + } + + return position; +} + +// NUMERIC POSITION TO DOM LOCATION + +interface DOMLocation { + node: Node; + offset: number; +} + +function findPositionInDOM(element: HTMLElement, position: number): DOMLocation | null { + const paragraphs = findAllParagraphs(element); + let cursorPos = 0; + + for (let pIndex = 0; pIndex < paragraphs.length; pIndex++) { + const p = paragraphs[pIndex]; + + if (pIndex > 0) { + cursorPos += 1; + if (cursorPos >= position) { + return { node: p, offset: 0 }; + } + } + + const paragraphLength = countParagraphContent(p); + + if (cursorPos + paragraphLength >= position) { + const targetOffset = position - cursorPos; + let offsetInParagraph = 0; + + for (const child of Array.from(p.childNodes)) { + if (isTextNode(child)) { + const textLength = child.textContent?.length || 0; + + if (offsetInParagraph + textLength >= targetOffset) { + return { node: child, offset: targetOffset - offsetInParagraph }; + } + + offsetInParagraph += textLength; + } else if (isHTMLElement(child)) { + const tokenType = getTokenType(child); + + if (tokenType === ELEMENT_TYPES.TRIGGER) { + const triggerLength = child.textContent?.length || 0; + + if (offsetInParagraph + triggerLength >= targetOffset) { + const offsetInTrigger = targetOffset - offsetInParagraph; + const triggerTextNode = child.childNodes[0]; + if (triggerTextNode && isTextNode(triggerTextNode)) { + return { node: triggerTextNode, offset: offsetInTrigger }; + } + } + + offsetInParagraph += triggerLength; + } else if (isReferenceTokenType(tokenType)) { + if (offsetInParagraph === targetOffset) { + return { node: p, offset: Array.from(p.childNodes).indexOf(child) }; + } + + offsetInParagraph += 1; + + if (offsetInParagraph === targetOffset) { + const nextSibling = child.nextSibling; + if (nextSibling) { + return isTextNode(nextSibling) + ? { node: nextSibling, offset: 0 } + : { node: p, offset: Array.from(p.childNodes).indexOf(nextSibling) }; + } + return { node: p, offset: p.childNodes.length }; + } + } + } + } + + return p.lastChild && isTextNode(p.lastChild) + ? { node: p.lastChild, offset: p.lastChild.textContent?.length || 0 } + : { node: p, offset: p.childNodes.length }; + } + + cursorPos += paragraphLength; + } + + const lastP = paragraphs[paragraphs.length - 1]; + if (lastP) { + return lastP.lastChild && isTextNode(lastP.lastChild) + ? { node: lastP.lastChild, offset: lastP.lastChild.textContent?.length || 0 } + : { node: lastP, offset: lastP.childNodes.length }; + } + + return null; +} + +export function setCursorPosition(element: HTMLElement, position: number): void { + const location = findPositionInDOM(element, position); + if (location) { + positionCursorAtOffset(location.node, location.offset); + } +} + +export function setCursorRange(element: HTMLElement, start: number, end: number): void { + const selection = window.getSelection(); + if (!selection) { + return; + } + + const startLocation = findPositionInDOM(element, start); + const endLocation = findPositionInDOM(element, end); + + if (!startLocation || !endLocation) { + return; + } + + const range = document.createRange(); + range.setStart(startLocation.node, startLocation.offset); + range.setEnd(endLocation.node, endLocation.offset); + selection.removeAllRanges(); + selection.addRange(range); +} + +// TOKEN CURSOR CALCULATIONS + +export function getTokenCursorLength(token: PromptInputProps.InputToken): number { + if (isTextToken(token)) { + return token.value.length; + } + if (isBreakToken(token)) { + return 0; + } + return 1; +} + +export function getCursorPositionAtIndex(tokens: readonly PromptInputProps.InputToken[], index: number): number { + let position = 0; + + for (let i = 0; i <= index && i < tokens.length; i++) { + position += getTokenCursorLength(tokens[i]); + } + + return position; +} + +// TRIGGER TOKEN UTILITIES + +/** + * Checks if the current cursor position is inside a trigger token element. + * @param element The contentEditable element + * @returns true if cursor is inside a trigger token, false otherwise + */ +export function isCursorInTriggerToken(element: HTMLElement): boolean { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return false; + } + + const range = selection.getRangeAt(0); + let node: Node | null = range.startContainer; + + // Walk up the DOM tree to check if we're inside a trigger token + while (node && node !== element) { + if (isHTMLElement(node) && getTokenType(node) === ELEMENT_TYPES.TRIGGER) { + return true; + } + node = node.parentNode; + } + + return false; +} diff --git a/src/prompt-input/core/event-handlers.ts b/src/prompt-input/core/event-handlers.ts new file mode 100644 index 0000000000..fb5fbb3f46 --- /dev/null +++ b/src/prompt-input/core/event-handlers.ts @@ -0,0 +1,905 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; +import { ELEMENT_TYPES } from './constants'; +import { getTokenCursorLength, positionAfter, positionBefore } from './cursor-manager'; +import { MenuItemsHandlers, MenuItemsState } from './menu-state'; +import { extractTokensFromDOM } from './token-extractor'; +import { isBRElement, isHTMLElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; +import { + createParagraph, + createTrailingBreak, + findAllParagraphs, + getTokenType, + insertAfter, + isElementEffectivelyEmpty, +} from './utils'; + +// TYPES + +export interface KeyboardHandlerDeps { + getMenuOpen: () => boolean; + getMenuItemsState: () => MenuItemsState | null; + getMenuItemsHandlers: () => MenuItemsHandlers | null; + onAction?: (detail: PromptInputProps.ActionDetail) => void; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + tokens?: readonly PromptInputProps.InputToken[]; + getPromptText: (tokens: readonly PromptInputProps.InputToken[]) => string; + closeMenu: () => void; + announceTokenOperation?: (message: string) => void; + i18nStrings?: PromptInputProps.I18nStrings; +} + +interface DeletionContext { + cursorPosition: number; + paragraphId: string | null; +} + +/** + * Shared state for coordinating between event handlers and input processing + */ +export interface EditableState { + skipNextZwnjUpdate: boolean; + skipNormalization: boolean; + skipCursorRestore: boolean; + targetParagraphId: string | null; + deletionContext: DeletionContext | null; + menuSelectionTokenId: string | null; + menuSelectionIsPinned: boolean; +} + +export function createEditableState(): EditableState { + return { + skipNextZwnjUpdate: false, + skipNormalization: false, + skipCursorRestore: false, + targetParagraphId: null, + deletionContext: null, + menuSelectionTokenId: null, + menuSelectionIsPinned: false, + }; +} + +// KEYBOARD HANDLERS + +export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { + function handleMenuNavigation(event: React.KeyboardEvent): boolean { + const menuItemsState = deps.getMenuItemsState(); + const menuItemsHandlers = deps.getMenuItemsHandlers(); + const menuOpen = deps.getMenuOpen(); + + if (!menuOpen || !menuItemsHandlers || !menuItemsState) { + return false; + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + + const delta = event.key === 'ArrowDown' ? 1 : -1; + menuItemsHandlers.moveHighlightWithKeyboard(delta); + return true; + } + + if ((event.key === 'Enter' || event.key === 'Tab') && !event.shiftKey) { + event.preventDefault(); + return menuItemsHandlers.selectHighlightedOptionWithKeyboard(); + } + + if (event.key === 'Escape') { + event.preventDefault(); + deps.closeMenu(); + return true; + } + + return false; + } + + function handleEnterKey(event: React.KeyboardEvent): void { + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) { + return; + } + + const currentTarget = event.currentTarget; + if (!isHTMLElement(currentTarget)) { + return; + } + + const form = currentTarget.closest('form'); + if (form && !event.isDefaultPrevented()) { + form.requestSubmit(); + } + event.preventDefault(); + + const plainText = deps.tokensToText ? deps.tokensToText(deps.tokens ?? []) : deps.getPromptText(deps.tokens ?? []); + + if (deps.onAction) { + deps.onAction({ value: plainText, tokens: [...(deps.tokens ?? [])] }); + } + } + + return { + handleMenuNavigation, + handleEnterKey, + }; +} + +// PARAGRAPH MERGING + +export function handleBackspaceAtParagraphStart( + event: React.KeyboardEvent, + editableElement: HTMLDivElement, + tokens: readonly PromptInputProps.InputToken[], + tokensToText: ((tokens: readonly PromptInputProps.InputToken[]) => string) | undefined, + getPromptText: (tokens: readonly PromptInputProps.InputToken[]) => string, + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, + setCursorPosition: (element: HTMLElement, position: number) => void, + state?: EditableState +): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + + if (range.startOffset !== 0 || range.startContainer.nodeName !== 'P') { + return false; + } + + const paragraphs = findAllParagraphs(editableElement); + const currentP = range.startContainer; + const pIndex = Array.from(paragraphs).indexOf(currentP as HTMLParagraphElement); + + if (pIndex <= 0) { + return false; + } + + event.preventDefault(); + + let breakCount = 0; + let cursorPosition = 0; + + const newTokens = tokens.filter(token => { + if (token.type === 'break') { + breakCount++; + if (breakCount === pIndex) { + return false; + } + cursorPosition += 1; + } else { + if (breakCount < pIndex) { + cursorPosition += getTokenCursorLength(token); + } + } + return true; + }); + + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + onChange({ value, tokens: newTokens }); + + // Store the target position for restoration after re-render + if (state) { + state.deletionContext = { + cursorPosition, + paragraphId: null, + }; + state.skipCursorRestore = false; + } else { + // Fallback for backward compatibility + requestAnimationFrame(() => { + setCursorPosition(editableElement, cursorPosition); + }); + } + + return true; +} + +export function handleDeleteAtParagraphEnd( + event: React.KeyboardEvent, + editableElement: HTMLDivElement, + tokens: readonly PromptInputProps.InputToken[], + tokensToText: ((tokens: readonly PromptInputProps.InputToken[]) => string) | undefined, + getPromptText: (tokens: readonly PromptInputProps.InputToken[]) => string, + cursorPosition: number, + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, + setCursorPosition: (element: HTMLElement, position: number) => void, + state?: EditableState +): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + const container = range.startContainer; + + let isAtEndOfParagraph = false; + let currentP: HTMLParagraphElement | null = null; + + if (container.nodeName === 'P') { + currentP = container as HTMLParagraphElement; + const hasOnlyTrailingBR = + currentP.childNodes.length === 1 && isBRElement(currentP.firstChild, ELEMENT_TYPES.TRAILING_BREAK); + isAtEndOfParagraph = hasOnlyTrailingBR || range.startOffset === currentP.childNodes.length; + } else if (isTextNode(container)) { + isAtEndOfParagraph = range.startOffset === (container.textContent?.length || 0) && !container.nextSibling; + let node: Node | null = container; + while (node && node.nodeName !== 'P') { + node = node.parentNode; + } + currentP = node as HTMLParagraphElement; + } + + if (!isAtEndOfParagraph || !currentP) { + return false; + } + + const paragraphs = findAllParagraphs(editableElement); + const pIndex = Array.from(paragraphs).indexOf(currentP); + + if (pIndex < 0 || pIndex >= paragraphs.length - 1) { + return false; + } + + event.preventDefault(); + + let breakCount = 0; + + const newTokens = tokens.filter(token => { + if (token.type === 'break') { + breakCount++; + return breakCount !== pIndex + 1; + } + return true; + }); + + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + onChange({ value, tokens: newTokens }); + + // Store the target position for restoration after re-render + if (state) { + state.deletionContext = { + cursorPosition, + paragraphId: null, + }; + state.skipCursorRestore = false; + } else { + // Fallback for backward compatibility + requestAnimationFrame(() => { + setCursorPosition(editableElement, cursorPosition); + }); + } + + return true; +} + +// PARAGRAPH OPERATIONS + +function findParagraphAncestor(node: Node): HTMLElement | null { + let current: Node | null = node; + while (current && current.nodeName !== 'P') { + current = current.parentNode; + } + return isHTMLElement(current) ? current : null; +} + +export function splitParagraphAtCursor( + editableElement: HTMLDivElement, + state: EditableState, + suppressInputEvent = false +): void { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + const currentP = findParagraphAncestor(range.startContainer); + + if (!currentP?.parentNode) { + return; + } + + const afterRange = document.createRange(); + afterRange.setStart(range.startContainer, range.startOffset); + afterRange.setEndAfter(currentP.lastChild || currentP); + const afterContent = afterRange.extractContents(); + + const newP = createParagraph(); + newP.appendChild(afterContent); + + if (isElementEffectivelyEmpty(newP)) { + newP.appendChild(createTrailingBreak()); + } + + if (isElementEffectivelyEmpty(currentP)) { + currentP.appendChild(createTrailingBreak()); + } + + currentP.parentNode.insertBefore(newP, currentP.nextSibling); + + // Calculate cursor position for the new paragraph (at its start) + // Count all tokens before the split point + const paragraphs = findAllParagraphs(editableElement); + const currentPIndex = paragraphs.findIndex(p => p === currentP); + + let cursorPosition = 0; + const tokens = extractTokensFromDOM(editableElement); + let breakCount = 0; + + for (const token of tokens) { + if (token.type === 'break') { + breakCount++; + cursorPosition += 1; + if (breakCount > currentPIndex) { + break; + } + } else { + cursorPosition += getTokenCursorLength(token); + } + } + + state.skipCursorRestore = false; + state.targetParagraphId = newP.getAttribute('data-paragraph-id'); + // Store the calculated position for unified restoration + state.deletionContext = { + cursorPosition, + paragraphId: newP.getAttribute('data-paragraph-id'), + }; + + if (!suppressInputEvent) { + editableElement.dispatchEvent(new Event('input', { bubbles: true })); + } +} + +// TOKEN DELETION HELPERS + +interface TokenElementResult { + targetElement: HTMLElement | null; + wrapperElement: HTMLElement | null; +} + +function findTokenElementForBackspace(container: Node, offset: number): TokenElementResult { + if (isTextNode(container) && offset === 0) { + const prev = container.previousSibling; + const prevType = isHTMLElement(prev) ? getTokenType(prev) : null; + if (prevType === ELEMENT_TYPES.REFERENCE || prevType === ELEMENT_TYPES.PINNED) { + return { + wrapperElement: prev as HTMLElement, + targetElement: prev as HTMLElement, + }; + } + } else if (isHTMLElement(container) && offset > 0) { + const prev = container.childNodes[offset - 1]; + const prevType = isHTMLElement(prev) ? getTokenType(prev) : null; + if (prevType === ELEMENT_TYPES.REFERENCE || prevType === ELEMENT_TYPES.PINNED) { + return { + wrapperElement: prev as HTMLElement, + targetElement: prev as HTMLElement, + }; + } + } + + return { targetElement: null, wrapperElement: null }; +} + +function findTokenElementForDelete(container: Node, offset: number): TokenElementResult { + if (isTextNode(container) && offset === (container.textContent?.length || 0)) { + const next = container.nextSibling; + const nextType = isHTMLElement(next) ? getTokenType(next) : null; + if (nextType === ELEMENT_TYPES.REFERENCE || nextType === ELEMENT_TYPES.PINNED) { + return { + wrapperElement: next as HTMLElement, + targetElement: next as HTMLElement, + }; + } + } else if (isHTMLElement(container)) { + const next = container.childNodes[offset]; + const nextType = isHTMLElement(next) ? getTokenType(next) : null; + if (nextType === ELEMENT_TYPES.REFERENCE || nextType === ELEMENT_TYPES.PINNED) { + return { + wrapperElement: next as HTMLElement, + targetElement: next as HTMLElement, + }; + } + } + + return { targetElement: null, wrapperElement: null }; +} + +function isValidTokenForDeletion(element: HTMLElement | null): boolean { + if (!element) { + return false; + } + const tokenType = getTokenType(element); + return tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED; +} + +export function handleReferenceTokenDeletion( + event: React.KeyboardEvent, + isBackspace: boolean, + editableElement: HTMLDivElement, + state: EditableState, + announceTokenOperation?: (message: string) => void, + i18nStrings?: PromptInputProps.I18nStrings +): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + + // If there's a selection range (not just a cursor), let the browser handle it + // The input event will trigger token extraction which will properly handle reference removal + if (!range.collapsed) { + return false; + } + + const { targetElement, wrapperElement } = isBackspace + ? findTokenElementForBackspace(range.startContainer, range.startOffset) + : findTokenElementForDelete(range.startContainer, range.startOffset); + + const finalTarget = targetElement || wrapperElement || null; + + if (!isValidTokenForDeletion(finalTarget)) { + return false; + } + + event.preventDefault(); + + // Announce token removal + const tokenLabel = finalTarget!.textContent?.trim() || ''; + if (announceTokenOperation && tokenLabel) { + const announcement = + i18nStrings?.tokenRemovedAriaLabel?.({ label: tokenLabel, value: tokenLabel }) ?? `${tokenLabel} removed`; + announceTokenOperation(announcement); + } + + const elementToRemove = (wrapperElement || finalTarget)!; + const paragraph = elementToRemove.parentNode; + if (!isHTMLElement(paragraph)) { + return true; + } + + state.skipNextZwnjUpdate = true; + state.skipNormalization = true; + + // Find the reference token's position in the token array + // This gives us the correct position independent of DOM structure + const instanceId = finalTarget!.getAttribute('data-id'); + const tokens = extractTokensFromDOM(editableElement); + const referenceIndex = tokens.findIndex(t => isReferenceToken(t) && t.id === instanceId); + + let targetCursorPosition = 0; + if (referenceIndex >= 0) { + // Calculate position up to (but not including) the reference + for (let i = 0; i < referenceIndex; i++) { + const token = tokens[i]; + if (isTextToken(token)) { + targetCursorPosition += token.value.length; + } else if (isTriggerToken(token)) { + targetCursorPosition += 1 + token.value.length; + } else { + targetCursorPosition += 1; // other references + } + } + + // For delete, cursor stays before the reference (already calculated) + // For backspace, cursor also goes before the reference (same position) + } + + // Store the target position for restoration after re-render + state.deletionContext = { + cursorPosition: targetCursorPosition, + paragraphId: null, + }; + state.skipCursorRestore = false; // Allow restoration with our calculated position + + elementToRemove.remove(); + editableElement.dispatchEvent(new Event('input', { bubbles: true })); + + return true; +} + +// ARROW KEY NAVIGATION + +function handleArrowInElementNode( + event: React.KeyboardEvent, + container: Node, + offset: number, + skipNormalizationRef: React.MutableRefObject +): boolean { + if (!isHTMLElement(container)) { + return false; + } + + const isLeftArrow = event.key === 'ArrowLeft'; + const sibling = isLeftArrow + ? offset > 0 + ? container.childNodes[offset - 1] + : container.previousSibling + : offset < container.childNodes.length + ? container.childNodes[offset] + : container.nextSibling; + + const siblingType = isHTMLElement(sibling) ? getTokenType(sibling) : null; + if (siblingType === ELEMENT_TYPES.REFERENCE || siblingType === ELEMENT_TYPES.PINNED) { + event.preventDefault(); + skipNormalizationRef.current = true; + isLeftArrow ? positionBefore(sibling as HTMLElement) : positionAfter(sibling as HTMLElement); + return true; + } + + return false; +} + +function handleArrowInTextNode( + event: React.KeyboardEvent, + container: Node, + offset: number, + skipNormalizationRef: React.MutableRefObject +): boolean { + if (!isTextNode(container)) { + return false; + } + + const isLeftArrow = event.key === 'ArrowLeft'; + const isAtBoundary = isLeftArrow ? offset === 0 : offset === (container.textContent?.length || 0); + + if (!isAtBoundary) { + return false; + } + + const sibling = isLeftArrow ? container.previousSibling : container.nextSibling; + + const siblingType = isHTMLElement(sibling) ? getTokenType(sibling) : null; + if (siblingType === ELEMENT_TYPES.REFERENCE || siblingType === ELEMENT_TYPES.PINNED) { + event.preventDefault(); + skipNormalizationRef.current = true; + isLeftArrow ? positionBefore(sibling as HTMLElement) : positionAfter(sibling as HTMLElement); + return true; + } + + return false; +} + +export function handleArrowKeyNavigation( + event: React.KeyboardEvent, + skipNormalizationRef: React.MutableRefObject +): boolean { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return false; + } + + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + const container = range.startContainer; + const offset = range.startOffset; + + // Handle Shift+Arrow for selection across reference tokens + if (event.shiftKey) { + return handleShiftArrowAcrossTokens(event, selection, range); + } + + return ( + handleArrowInElementNode(event, container, offset, skipNormalizationRef) || + handleArrowInTextNode(event, container, offset, skipNormalizationRef) + ); +} + +function handleShiftArrowAcrossTokens( + event: React.KeyboardEvent, + selection: Selection, + range: Range +): boolean { + const isLeftArrow = event.key === 'ArrowLeft'; + + // For Shift+Arrow, we need to check the moving end of the selection + // Left arrow moves the start, right arrow moves the end + const relevantContainer = isLeftArrow ? range.startContainer : range.endContainer; + const relevantOffset = isLeftArrow ? range.startOffset : range.endOffset; + + // Check if we're immediately adjacent to a reference token (treating it as atomic) + let sibling: Node | null = null; + + if (isTextNode(relevantContainer)) { + // In text node - check if at start/end boundary + if (isLeftArrow && relevantOffset === 0) { + sibling = relevantContainer.previousSibling; + } else if (!isLeftArrow && relevantOffset === (relevantContainer.textContent?.length || 0)) { + sibling = relevantContainer.nextSibling; + } + } else if (isHTMLElement(relevantContainer)) { + // In element node (paragraph) - check adjacent child + if (isLeftArrow && relevantOffset > 0) { + sibling = relevantContainer.childNodes[relevantOffset - 1]; + } else if (!isLeftArrow && relevantOffset < relevantContainer.childNodes.length) { + sibling = relevantContainer.childNodes[relevantOffset]; + } + } + + if (!sibling) { + return false; + } + + const siblingType = isHTMLElement(sibling) ? getTokenType(sibling) : null; + if (siblingType === ELEMENT_TYPES.REFERENCE || siblingType === ELEMENT_TYPES.PINNED) { + event.preventDefault(); + + // Extend selection to include the entire reference token (atomic) + const newRange = range.cloneRange(); + if (isLeftArrow) { + newRange.setStartBefore(sibling); + } else { + newRange.setEndAfter(sibling); + } + + selection.removeAllRanges(); + selection.addRange(newRange); + return true; + } + + return false; +} + +// CURSOR NORMALIZATION + +function normalizeCursorInCursorSpot(container: Node): void { + if (!isTextNode(container)) { + return; + } + + const parent = container.parentElement; + if (!parent) { + return; + } + + const parentType = getTokenType(parent); + if (parentType !== ELEMENT_TYPES.CURSOR_SPOT_BEFORE && parentType !== ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + return; + } + + const wrapper = parent.parentElement; + const wrapperType = wrapper ? getTokenType(wrapper) : null; + if (!wrapper || (wrapperType !== ELEMENT_TYPES.REFERENCE && wrapperType !== ELEMENT_TYPES.PINNED)) { + return; + } + + const paragraph = wrapper.parentElement; + if (paragraph?.nodeName !== 'P') { + return; + } + + parentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE ? positionBefore(wrapper) : positionAfter(wrapper); +} + +export function createCursorNormalizationHandler( + editableElementRef: React.RefObject, + skipNormalizationRef: React.MutableRefObject, + state: EditableState +): () => void { + return () => { + if (skipNormalizationRef.current) { + skipNormalizationRef.current = false; + return; + } + + if (state.skipNormalization) { + state.skipNormalization = false; + return; + } + + const editableElement = editableElementRef.current; + if (!editableElement) { + return; + } + + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + + // Skip normalization if there's an active selection (not just a collapsed cursor) + // This allows text selection including reference tokens to work correctly + if (!range.collapsed) { + return; + } + + normalizeCursorInCursorSpot(range.startContainer); + }; +} + +// SELECTION NORMALIZATION + +/** + * Normalizes selection to include entire reference tokens when selection boundary is in cursor spots. + * If selection starts or ends in a cursor spot, expands to include the entire reference wrapper. + */ +function normalizeSelectionAroundReferences(): void { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + + // Only normalize non-collapsed selections + if (range.collapsed) { + return; + } + + let modified = false; + let newStartContainer = range.startContainer; + let newStartOffset = range.startOffset; + let newEndContainer = range.endContainer; + let newEndOffset = range.endOffset; + + // Check if start is in a cursor spot + if (isTextNode(range.startContainer)) { + const startParent = range.startContainer.parentElement; + if (startParent) { + const startParentType = getTokenType(startParent); + if (startParentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE || startParentType === ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + const wrapper = startParent.parentElement; + const wrapperType = wrapper ? getTokenType(wrapper) : null; + if (wrapper && (wrapperType === ELEMENT_TYPES.REFERENCE || wrapperType === ELEMENT_TYPES.PINNED)) { + const paragraph = wrapper.parentElement; + if (paragraph) { + // If in cursor-spot-before, expand to before wrapper + // If in cursor-spot-after, expand to after wrapper + if (startParentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE) { + newStartContainer = paragraph; + newStartOffset = Array.from(paragraph.childNodes).indexOf(wrapper); + } else { + newStartContainer = paragraph; + newStartOffset = Array.from(paragraph.childNodes).indexOf(wrapper) + 1; + } + modified = true; + } + } + } + } + } + + // Check if end is in a cursor spot + if (isTextNode(range.endContainer)) { + const endParent = range.endContainer.parentElement; + if (endParent) { + const endParentType = getTokenType(endParent); + if (endParentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE || endParentType === ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + const wrapper = endParent.parentElement; + const wrapperType = wrapper ? getTokenType(wrapper) : null; + if (wrapper && (wrapperType === ELEMENT_TYPES.REFERENCE || wrapperType === ELEMENT_TYPES.PINNED)) { + const paragraph = wrapper.parentElement; + if (paragraph) { + // If in cursor-spot-before, expand to before wrapper + // If in cursor-spot-after, expand to after wrapper + if (endParentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE) { + newEndContainer = paragraph; + newEndOffset = Array.from(paragraph.childNodes).indexOf(wrapper); + } else { + newEndContainer = paragraph; + newEndOffset = Array.from(paragraph.childNodes).indexOf(wrapper) + 1; + } + modified = true; + } + } + } + } + } + + if (modified) { + const newRange = document.createRange(); + newRange.setStart(newStartContainer, newStartOffset); + newRange.setEnd(newEndContainer, newEndOffset); + selection.removeAllRanges(); + selection.addRange(newRange); + } +} + +export function createSelectionNormalizationHandler(): () => void { + return () => { + normalizeSelectionAroundReferences(); + }; +} + +// SPACE AFTER CLOSED TRIGGER + +export function handleSpaceAfterClosedTrigger( + event: React.KeyboardEvent, + editableElement: HTMLDivElement, + menuOpen: boolean, + triggerValueWhenClosed: string, + editableState: EditableState +): boolean { + // Only handle space key when menu is closed and we have a saved trigger length + if (event.key !== ' ' || menuOpen || !triggerValueWhenClosed) { + return false; + } + + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + if (!range.collapsed) { + return false; + } + + // Check if cursor is at the end of a trigger element + let triggerElement: HTMLElement | null = null; + let cursorAtEnd = false; + + if (isTextNode(range.startContainer)) { + const parent = range.startContainer.parentElement; + const parentType = parent ? getTokenType(parent) : null; + + if (parentType === ELEMENT_TYPES.TRIGGER && parent) { + triggerElement = parent; + const textLength = range.startContainer.textContent?.length || 0; + cursorAtEnd = range.startOffset === textLength; + + // Extract filter text (everything after trigger char) + const fullText = triggerElement.textContent || ''; + const filterText = fullText.substring(1); + + // Only handle if filter text matches saved length (space hasn't been added yet) + // If it's longer, the space was already added and we shouldn't handle it again + if (filterText.length !== triggerValueWhenClosed.length) { + return false; + } + } + } + + if (!triggerElement || !cursorAtEnd) { + return false; + } + + // Prevent default space insertion + event.preventDefault(); + + // Get the paragraph containing the trigger + const paragraph = triggerElement.parentElement; + if (!paragraph || paragraph.nodeName !== 'P') { + return false; + } + + // Insert space after trigger + const spaceNode = document.createTextNode(' '); + insertAfter(spaceNode, triggerElement); + + // Calculate cursor position after the space for unified restoration + const tokens = extractTokensFromDOM(editableElement); + let cursorPosition = 0; + let foundTrigger = false; + + for (const token of tokens) { + if (token.type === 'trigger' && !foundTrigger) { + cursorPosition += getTokenCursorLength(token) + 1; // trigger + space + foundTrigger = true; + break; + } + cursorPosition += getTokenCursorLength(token); + } + + // Store position for unified restoration + editableState.deletionContext = { + cursorPosition, + paragraphId: null, + }; + editableState.skipCursorRestore = false; + + // Trigger input event to extract tokens and update state + editableElement.dispatchEvent(new Event('input', { bubbles: true })); + + return true; +} diff --git a/src/prompt-input/core/menu-state.ts b/src/prompt-input/core/menu-state.ts new file mode 100644 index 0000000000..6a00162df3 --- /dev/null +++ b/src/prompt-input/core/menu-state.ts @@ -0,0 +1,216 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo, useRef } from 'react'; + +import { filterOptions } from '../../autosuggest/utils/utils'; +import { DropdownStatusProps } from '../../internal/components/dropdown-status/interfaces'; +import { OptionDefinition, OptionGroup } from '../../internal/components/option/interfaces'; +import { generateTestIndexes } from '../../internal/components/options-list/utils/test-indexes'; +import { + HighlightedOptionHandlers, + HighlightedOptionState, + useHighlightedOption, +} from '../../internal/components/options-list/utils/use-highlight-option'; +import { PromptInputProps } from '../interfaces'; + +// TYPES + +export type MenuItem = (OptionDefinition | OptionGroup) & { + type?: 'parent' | 'child' | 'use-entered'; + option: OptionDefinition | OptionGroup; +}; + +export interface UseMenuItemsProps { + menu: PromptInputProps.MenuDefinition; + filterText: string; + onSelectItem: (option: MenuItem) => void; +} + +export interface MenuItemsState extends HighlightedOptionState { + items: readonly MenuItem[]; + showAll: boolean; + getItemGroup: (item: MenuItem) => undefined | OptionGroup; +} + +export interface MenuItemsHandlers extends HighlightedOptionHandlers { + selectHighlightedOptionWithKeyboard(): boolean; + highlightVisibleOptionWithMouse(index: number): void; + selectVisibleOptionWithMouse(index: number): void; +} + +interface UseMenuLoadMoreProps { + menu: PromptInputProps.MenuDefinition; + statusType: DropdownStatusProps.StatusType; + onLoadItems: (detail: PromptInputProps.MenuLoadItemsDetail) => void; + onLoadMoreItems?: () => void; +} + +interface MenuLoadMoreHandlers { + fireLoadMoreOnScroll(): void; + fireLoadMoreOnRecoveryClick(): void; + fireLoadMoreOnMenuOpen(): void; + fireLoadMoreOnInputChange(filteringText: string): void; +} + +// MENU ITEMS + +function isMenuItemHighlightable(option?: MenuItem): boolean { + return !!option && option.type !== 'parent'; +} + +function isMenuItemInteractive(option?: MenuItem): boolean { + return !!option && !option.disabled && option.type !== 'parent'; +} + +export const useMenuItems = ({ + menu, + filterText, + onSelectItem, +}: UseMenuItemsProps): [MenuItemsState, MenuItemsHandlers] => { + const { items, getItemGroup, getItemParent } = useMemo(() => createItems(menu.options), [menu.options]); + + const filteredItems = useMemo(() => { + const filteringType = menu.filteringType ?? 'auto'; + const filtered: MenuItem[] = + filteringType === 'auto' ? (filterOptions(items, filterText) as MenuItem[]) : [...items]; + generateTestIndexes(filtered, getItemParent); + return filtered; + }, [menu.filteringType, items, filterText, getItemParent]); + + const [highlightedOptionState, highlightedOptionHandlers] = useHighlightedOption({ + options: filteredItems, + isHighlightable: isMenuItemHighlightable, + }); + + const selectHighlightedOptionWithKeyboard = () => { + const { highlightedOption } = highlightedOptionState; + if (!highlightedOption || !isMenuItemInteractive(highlightedOption)) { + return false; + } + onSelectItem(highlightedOption); + return true; + }; + + const highlightVisibleOptionWithMouse = (index: number) => { + const item = filteredItems[index]; + if (item && isMenuItemHighlightable(item)) { + highlightedOptionHandlers.setHighlightedIndexWithMouse(index); + } + }; + + const selectVisibleOptionWithMouse = (index: number) => { + const item = filteredItems[index]; + if (item && isMenuItemInteractive(item)) { + onSelectItem(item); + } + }; + + return [ + { ...highlightedOptionState, items: filteredItems, showAll: false, getItemGroup }, + { + ...highlightedOptionHandlers, + selectHighlightedOptionWithKeyboard, + highlightVisibleOptionWithMouse, + selectVisibleOptionWithMouse, + }, + ]; +}; + +function createItems(options: readonly OptionDefinition[]) { + const items: MenuItem[] = []; + const itemToGroup = new WeakMap(); + const getItemParent = (item: MenuItem) => itemToGroup.get(item); + const getItemGroup = (item: MenuItem) => getItemParent(item)?.option as OptionGroup; + + for (const option of options) { + if (isGroup(option)) { + for (const item of flattenGroup(option)) { + items.push(item); + } + } else { + items.push({ ...option, option }); + } + } + + function flattenGroup(group: OptionGroup) { + const { options, ...rest } = group; + + let hasOnlyDisabledChildren = true; + + const groupItem: MenuItem = { ...rest, type: 'parent', option: group }; + + const items: MenuItem[] = [groupItem]; + + for (const option of options) { + if (!option.disabled) { + hasOnlyDisabledChildren = false; + } + + const childOption: MenuItem = { + ...option, + type: 'child', + disabled: option.disabled || rest.disabled, + option, + }; + + items.push(childOption); + + itemToGroup.set(childOption, groupItem); + } + + items[0].disabled = items[0].disabled || hasOnlyDisabledChildren; + + return items; + } + + return { items, getItemGroup, getItemParent }; +} + +function isGroup(optionOrGroup: OptionDefinition): optionOrGroup is OptionGroup { + return 'options' in optionOrGroup; +} + +// MENU LOAD MORE + +export const useMenuLoadMore = ({ + menu, + statusType, + onLoadItems, + onLoadMoreItems, +}: UseMenuLoadMoreProps): MenuLoadMoreHandlers => { + const lastFilteringText = useRef(null); + + const fireLoadMore = (firstPage: boolean, samePage: boolean, filteringText?: string) => { + if (filteringText !== undefined && filteringText !== lastFilteringText.current) { + lastFilteringText.current = filteringText; + } + + if (filteringText === undefined || lastFilteringText.current !== filteringText) { + onLoadItems({ + menuId: menu.id, + filteringText: lastFilteringText.current ?? '', + firstPage, + samePage, + }); + } + }; + + const fireLoadMoreOnScroll = () => { + if (menu.options.length > 0 && statusType === 'pending') { + if (onLoadMoreItems) { + onLoadMoreItems(); + } else { + fireLoadMore(false, false); + } + } + }; + + const fireLoadMoreOnRecoveryClick = () => fireLoadMore(false, true); + + const fireLoadMoreOnMenuOpen = () => fireLoadMore(true, false, lastFilteringText.current ?? ''); + + const fireLoadMoreOnInputChange = (filteringText: string) => fireLoadMore(true, false, filteringText); + + return { fireLoadMoreOnScroll, fireLoadMoreOnRecoveryClick, fireLoadMoreOnMenuOpen, fireLoadMoreOnInputChange }; +}; diff --git a/src/prompt-input/core/token-engine.ts b/src/prompt-input/core/token-engine.ts new file mode 100644 index 0000000000..63a976992b --- /dev/null +++ b/src/prompt-input/core/token-engine.ts @@ -0,0 +1,218 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PromptInputProps } from '../interfaces'; +import { getCursorPositionAtIndex, getTokenCursorLength } from './cursor-manager'; +import { isPinnedReferenceToken, isReferenceToken, isTextToken, isTriggerToken } from './type-guards'; +import { generateTokenId } from './utils'; + +// TYPES + +export type UpdateSource = 'user-input' | 'external' | 'menu-selection' | 'internal'; + +export interface TokenUpdate { + tokens: PromptInputProps.InputToken[]; + source: UpdateSource; + cursorPosition?: number; +} + +export interface ShortcutsConfig { + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; +} + +export interface MenuSelectionResult { + tokens: PromptInputProps.InputToken[]; + cursorPosition: number; + insertedToken: PromptInputProps.ReferenceToken; +} + +// HELPER FUNCTIONS + +function areAllTokensPinned(tokens: readonly PromptInputProps.InputToken[]): boolean { + return tokens.every(isPinnedReferenceToken); +} + +function isTriggerValid( + menu: PromptInputProps.MenuDefinition, + triggerIndex: number, + text: string, + precedingTokens: readonly PromptInputProps.InputToken[] +): boolean { + const isAtStart = triggerIndex === 0; + const charBefore = triggerIndex > 0 ? text[triggerIndex - 1] : ''; + const isAfterWhitespace = /\s/.test(charBefore); + + if (menu.useAtStart) { + return isAtStart && areAllTokensPinned(precedingTokens); + } + + return isAtStart || isAfterWhitespace; +} + +// TRIGGER DETECTION + +export function detectTriggersInText( + text: string, + menus: readonly PromptInputProps.MenuDefinition[], + precedingTokens: readonly PromptInputProps.InputToken[] +): PromptInputProps.InputToken[] { + const results: PromptInputProps.InputToken[] = []; + let position = 0; + + while (position < text.length) { + let foundTrigger = false; + + for (const menu of menus) { + const triggerIndex = text.indexOf(menu.trigger, position); + if (triggerIndex === -1) { + continue; + } + + if (!isTriggerValid(menu, triggerIndex, text, precedingTokens)) { + continue; + } + + const beforeTrigger = text.substring(position, triggerIndex); + if (beforeTrigger) { + results.push({ type: 'text', value: beforeTrigger }); + } + + const afterTrigger = text.substring(triggerIndex + menu.trigger.length); + let filterText = ''; + let remainingText = afterTrigger; + + if (afterTrigger && !/^\s/.test(afterTrigger)) { + let endIndex = 0; + while (endIndex < afterTrigger.length && !/\s/.test(afterTrigger[endIndex])) { + endIndex++; + } + filterText = afterTrigger.substring(0, endIndex); + remainingText = afterTrigger.substring(endIndex); + } + + results.push({ + type: 'trigger', + value: filterText, + triggerChar: menu.trigger, + id: generateTokenId('trigger'), + }); + + if (remainingText) { + results.push({ type: 'text', value: remainingText }); + } + + position = text.length; + foundTrigger = true; + break; + } + + if (!foundTrigger) { + const remaining = text.substring(position); + if (remaining) { + results.push({ type: 'text', value: remaining }); + } + break; + } + } + + return results.length > 0 ? results : [{ type: 'text', value: text }]; +} + +export function detectTriggersInTokens( + tokens: readonly PromptInputProps.InputToken[], + menus: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const result: PromptInputProps.InputToken[] = []; + + for (const token of tokens) { + if (isTextToken(token)) { + const detectedTokens = detectTriggersInText(token.value, menus, result); + result.push(...detectedTokens); + } else { + result.push(token); + } + } + + return result; +} + +// MENU SELECTION + +export function handleMenuSelection( + tokens: readonly PromptInputProps.InputToken[], + selectedOption: { + value: string; + label?: string; + }, + menuId: string, + isPinned: boolean, + activeTrigger: PromptInputProps.TriggerToken +): MenuSelectionResult { + const newTokens = [...tokens]; + + const triggerIndex = newTokens.findIndex(t => isTriggerToken(t) && t.id === activeTrigger.id); + + if (isPinned) { + const pinnedToken: PromptInputProps.ReferenceToken = { + type: 'reference', + id: generateTokenId('ref'), + label: selectedOption.label || selectedOption.value || '', + value: selectedOption.value || '', + menuId, + pinned: true, + }; + + newTokens.splice(triggerIndex, 1); + + let insertIndex = 0; + while (insertIndex < newTokens.length && isPinnedReferenceToken(newTokens[insertIndex])) { + insertIndex++; + } + + newTokens.splice(insertIndex, 0, pinnedToken); + + const cursorPos = getCursorPositionAtIndex(newTokens, insertIndex); + return { tokens: newTokens, cursorPosition: cursorPos, insertedToken: pinnedToken }; + } else { + const referenceToken: PromptInputProps.ReferenceToken = { + type: 'reference', + id: generateTokenId('ref'), + label: selectedOption.label || selectedOption.value || '', + value: selectedOption.value || '', + menuId, + }; + + newTokens.splice(triggerIndex, 1, referenceToken); + + let cursorPos = 0; + for (const token of newTokens) { + cursorPos += getTokenCursorLength(token); + + if (isReferenceToken(token) && token.id === selectedOption.value) { + break; + } + } + + return { tokens: newTokens, cursorPosition: cursorPos, insertedToken: referenceToken }; + } +} + +// TOKEN PROCESSING + +export function processTokens( + tokens: readonly PromptInputProps.InputToken[], + config: ShortcutsConfig, + options: { + source: UpdateSource; + detectTriggers?: boolean; + } +): PromptInputProps.InputToken[] { + let result = [...tokens]; + + if (options.detectTriggers && config.menus) { + result = detectTriggersInTokens(result, config.menus); + } + + return result; +} diff --git a/src/prompt-input/core/token-extractor.ts b/src/prompt-input/core/token-extractor.ts new file mode 100644 index 0000000000..573751d34a --- /dev/null +++ b/src/prompt-input/core/token-extractor.ts @@ -0,0 +1,216 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OptionDefinition, OptionGroup } from '../../internal/components/option/interfaces'; +import { PromptInputProps } from '../interfaces'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { isBRElement, isHTMLElement, isPinnedReferenceToken, isTextNode } from './type-guards'; +import { findAllParagraphs, findElement, generateTokenId, getTokenType } from './utils'; + +// HELPER FUNCTIONS + +function findOptionInMenu( + options: readonly (OptionDefinition | OptionGroup)[], + labelOrValue: string +): OptionDefinition | undefined { + for (const item of options) { + if ('options' in item) { + // It's a group, search in its options + const found = item.options?.find(opt => opt.value === labelOrValue || opt.label === labelOrValue); + if (found) { + return found; + } + } else if (item.value === labelOrValue || item.label === labelOrValue) { + // It's an option + return item; + } + } + return undefined; +} + +export function extractTokensFromDOM( + element: HTMLElement, + menus?: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const paragraphs = findAllParagraphs(element); + + if (paragraphs.length === 0) { + return []; + } + + // Special case: single empty paragraph = empty input + if (paragraphs.length === 1) { + const p = paragraphs[0]; + const hasOnlyTrailingBr = p.childNodes.length === 1 && isBRElement(p.firstChild, ELEMENT_TYPES.TRAILING_BREAK); + + if (hasOnlyTrailingBr) { + return []; + } + } + + const allTokens: PromptInputProps.InputToken[] = []; + + paragraphs.forEach((p, pIndex) => { + const paragraphTokens = extractTokensFromParagraph(p, menus); + + if (pIndex > 0) { + allTokens.push({ type: 'break', value: SPECIAL_CHARS.NEWLINE }); + } + + allTokens.push(...paragraphTokens); + }); + + return allTokens; +} + +function extractTokensFromParagraph( + p: HTMLElement, + menus?: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const tokens: PromptInputProps.InputToken[] = []; + let textBuffer = ''; + + const flushText = () => { + if (textBuffer) { + tokens.push({ type: 'text', value: textBuffer }); + textBuffer = ''; + } + }; + + const processNode = (node: Node) => { + if (isTextNode(node)) { + const text = (node.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (text) { + textBuffer += text; + } + } else if (isHTMLElement(node)) { + if (node.tagName === 'BR') { + return; + } + + const tokenType = getTokenType(node); + + if (tokenType === ELEMENT_TYPES.TRIGGER) { + flushText(); + const id = node.getAttribute('data-id') || generateTokenId('trigger'); + const fullText = node.textContent || ''; + const triggerChar = fullText.charAt(0); + const value = fullText.substring(1); + + const token: PromptInputProps.TriggerToken = { + type: 'trigger', + value, + triggerChar, + id, + }; + tokens.push(token); + } else if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { + flushText(); + + const cursorSpotBefore = findElement(node, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }); + if (cursorSpotBefore) { + const beforeText = (cursorSpotBefore.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (beforeText) { + tokens.push({ type: 'text', value: beforeText }); + } + } + + // Extract label from token's text content (excluding cursor spots) + let label = ''; + for (const child of Array.from(node.childNodes)) { + if (isTextNode(child)) { + label += child.textContent || ''; + } else if (isHTMLElement(child)) { + const childType = getTokenType(child); + if (childType !== ELEMENT_TYPES.CURSOR_SPOT_BEFORE && childType !== ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + label += child.textContent || ''; + } + } + } + label = label.replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), '').trim(); + + const instanceId = node.getAttribute('data-id') || ''; + const menuId = node.getAttribute('data-menu-id') || ''; + + // Look up option from menu definition using the label + let value = ''; + if (menuId && menus && label) { + const menu = menus.find(m => m.id === menuId); + if (menu) { + const option = findOptionInMenu(menu.options, label); + if (option) { + value = option.value || ''; + label = option.label || option.value || label; + } + } + } + + const token: PromptInputProps.ReferenceToken = { + type: 'reference', + id: instanceId, + value, + label, + menuId, + }; + if (tokenType === ELEMENT_TYPES.PINNED) { + token.pinned = true; + } + tokens.push(token); + + const cursorSpotAfter = findElement(node, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_AFTER }); + if (cursorSpotAfter) { + const afterText = (cursorSpotAfter.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (afterText) { + tokens.push({ type: 'text', value: afterText }); + } + } + } else { + Array.from(node.childNodes).forEach(processNode); + } + } + }; + + Array.from(p.childNodes).forEach(processNode); + flushText(); + + return tokens; +} + +export function getPromptText(tokens: readonly PromptInputProps.InputToken[]): string { + return tokens.map(token => token.value).join(''); +} + +export function findLastPinnedTokenIndex(tokens: readonly PromptInputProps.InputToken[]): number { + for (let i = tokens.length - 1; i >= 0; i--) { + if (isPinnedReferenceToken(tokens[i])) { + return i; + } + } + return -1; +} + +export function moveForbiddenTextAfterPinnedTokens( + tokens: readonly PromptInputProps.InputToken[] +): PromptInputProps.InputToken[] { + const lastPinnedIndex = findLastPinnedTokenIndex(tokens); + + if (lastPinnedIndex === -1) { + return [...tokens]; + } + + const pinnedTokens: PromptInputProps.InputToken[] = []; + const forbiddenContent: PromptInputProps.InputToken[] = []; + const allowedContent: PromptInputProps.InputToken[] = []; + + tokens.forEach((token, index) => { + if (isPinnedReferenceToken(token)) { + pinnedTokens.push(token); + } else if (index <= lastPinnedIndex) { + forbiddenContent.push(token); + } else { + allowedContent.push(token); + } + }); + + return [...pinnedTokens, ...forbiddenContent, ...allowedContent]; +} diff --git a/src/prompt-input/core/token-renderer.tsx b/src/prompt-input/core/token-renderer.tsx new file mode 100644 index 0000000000..ca92d876ee --- /dev/null +++ b/src/prompt-input/core/token-renderer.tsx @@ -0,0 +1,407 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import Token from '../../token/internal'; +import { PromptInputProps } from '../interfaces'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { isBreakToken, isBRElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; +import { + createParagraph, + createTrailingBreak, + findAllParagraphs, + findElement, + findElements, + generateTokenId, + getTokenType, + insertAfter, +} from './utils'; + +import styles from '../styles.css.js'; + +// REACT COMPONENT MANAGEMENT + +const rootsMap = new Map(); + +function renderComponent(element: React.ReactElement, container: HTMLElement): void { + if ('createRoot' in ReactDOM) { + const ReactDOMClient = ReactDOM as any; + let root = rootsMap.get(container); + if (!root) { + root = ReactDOMClient.createRoot(container); + rootsMap.set(container, root); + } + root.render(element); + } else { + ReactDOM.render(element, container); + } +} + +export function unmountComponent(container: HTMLElement): void { + const root = rootsMap.get(container); + if (root && 'unmount' in root) { + root.unmount(); + rootsMap.delete(container); + } else { + ReactDOM.unmountComponentAtNode(container); + } +} + +// DOM NORMALIZATION + +function normalizeParagraphsAfterRender(element: HTMLElement): void { + const paragraphs = findAllParagraphs(element); + + paragraphs.forEach(p => { + moveCursorSpotContentToParagraph(p); + removeLeadingBrowserBRs(p); + removeOrphanedZWNJ(p); + ensureEmptyParagraphsHaveTrailingBR(p); + removeTrailingBRFromStart(p); + removeMiddleTrailingBRs(p); + ensureCursorSpotsInWrappers(p); + ensureWrappersHaveAllParts(p); + ensureCursorSpotsHaveZWNJ(p); + }); +} + +function removeLeadingBrowserBRs(p: HTMLElement): void { + while (isBRElement(p.firstChild)) { + p.firstChild.remove(); + } +} + +function removeOrphanedZWNJ(p: HTMLElement): void { + Array.from(p.childNodes).forEach(node => { + if (isTextNode(node) && node.textContent === SPECIAL_CHARS.ZWNJ) { + node.remove(); + } + }); +} + +function ensureEmptyParagraphsHaveTrailingBR(p: HTMLElement): void { + if (p.childNodes.length === 0) { + p.appendChild(createTrailingBreak()); + } else if (p.childNodes.length === 1 && isTextNode(p.firstChild) && !p.firstChild.textContent?.trim()) { + p.innerHTML = ''; + p.appendChild(createTrailingBreak()); + } +} + +function removeTrailingBRFromStart(p: HTMLElement): void { + if (p.childNodes.length > 1 && isBRElement(p.firstChild, ELEMENT_TYPES.TRAILING_BREAK)) { + p.firstChild.remove(); + } +} + +function removeMiddleTrailingBRs(p: HTMLElement): void { + const children = Array.from(p.childNodes); + for (let i = 0; i < children.length - 1; i++) { + const child = children[i]; + if (isBRElement(child, ELEMENT_TYPES.TRAILING_BREAK)) { + child.remove(); + } + } +} + +function ensureCursorSpotsInWrappers(p: HTMLElement): void { + findElements(p, { tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER] }).forEach( + cursorSpot => { + const parent = cursorSpot.parentElement; + const parentType = parent ? getTokenType(parent) : null; + if (!parent || (parentType !== ELEMENT_TYPES.REFERENCE && parentType !== ELEMENT_TYPES.PINNED)) { + cursorSpot.remove(); + } + } + ); +} + +function ensureWrappersHaveAllParts(p: HTMLElement): void { + findElements(p, { tokenType: [ELEMENT_TYPES.REFERENCE, ELEMENT_TYPES.PINNED] }).forEach(wrapper => { + const cursorSpotBefore = findElement(wrapper, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }); + const cursorSpotAfter = findElement(wrapper, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_AFTER }); + + if (!cursorSpotBefore || !cursorSpotAfter) { + wrapper.remove(); + } + }); +} + +function ensureCursorSpotsHaveZWNJ(p: HTMLElement): void { + findElements(p, { tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER] }).forEach( + cursorSpot => { + cursorSpot.innerHTML = ''; + cursorSpot.appendChild(document.createTextNode(SPECIAL_CHARS.ZWNJ)); + } + ); +} + +function moveCursorSpotContentToParagraph(p: HTMLElement): void { + findElements(p, { tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER] }).forEach( + cursorSpot => { + const wrapper = cursorSpot.parentElement; + const wrapperType = wrapper ? getTokenType(wrapper) : null; + if (!wrapper || (wrapperType !== ELEMENT_TYPES.REFERENCE && wrapperType !== ELEMENT_TYPES.PINNED)) { + return; + } + + const text = (cursorSpot.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (!text) { + return; + } + + const isBefore = cursorSpot.getAttribute('data-type') === ELEMENT_TYPES.CURSOR_SPOT_BEFORE; + const textNode = document.createTextNode(text); + + if (isBefore) { + wrapper.parentElement?.insertBefore(textNode, wrapper); + } else { + insertAfter(textNode, wrapper); + } + + cursorSpot.textContent = SPECIAL_CHARS.ZWNJ; + + // Cursor positioning is handled by unified restoration system in use-editable-tokens + } + ); +} + +// TOKEN GROUPING + +interface ParagraphGroup { + tokens: PromptInputProps.InputToken[]; +} + +function groupTokensIntoParagraphs(tokens: readonly PromptInputProps.InputToken[]): ParagraphGroup[] { + if (tokens.length === 0) { + return [{ tokens: [] }]; + } + + const paragraphs: ParagraphGroup[] = []; + let currentParagraph: PromptInputProps.InputToken[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (isBreakToken(token)) { + // Check if this is a leading break (at start or after other breaks) + const isLeadingBreak = currentParagraph.length === 0; + + if (isLeadingBreak) { + // Leading break = create empty paragraph + paragraphs.push({ tokens: [] }); + } else { + // Break after content = end current paragraph + paragraphs.push({ tokens: currentParagraph }); + currentParagraph = []; + } + } else { + // Non-break token = add to current paragraph + currentParagraph.push(token); + } + } + + // Add final paragraph (always - could be empty from trailing break or have content) + paragraphs.push({ tokens: currentParagraph }); + + return paragraphs; +} + +// CURSOR SPOT CREATION +function createCursorSpot(type: string): HTMLSpanElement { + const cursorSpot = document.createElement('span'); + cursorSpot.setAttribute('data-type', type); + cursorSpot.setAttribute('contenteditable', 'true'); + cursorSpot.setAttribute('aria-hidden', 'true'); + cursorSpot.appendChild(document.createTextNode(SPECIAL_CHARS.ZWNJ)); + return cursorSpot; +} + +function createReferenceWithCursorSpots( + token: PromptInputProps.ReferenceToken, + reactContainers: Set, + disabled: boolean, + readOnly: boolean +): HTMLSpanElement { + const wrapper = document.createElement('span'); + wrapper.setAttribute('data-type', token.pinned ? ELEMENT_TYPES.PINNED : ELEMENT_TYPES.REFERENCE); + const instanceId = token.id || generateTokenId('ref'); + wrapper.setAttribute('data-id', instanceId); + wrapper.setAttribute('data-menu-id', token.menuId); + + const cursorSpotBefore = createCursorSpot(ELEMENT_TYPES.CURSOR_SPOT_BEFORE); + const container = document.createElement('span'); + container.className = styles['token-container']; + container.setAttribute('contenteditable', 'false'); + + reactContainers.add(container); + renderComponent( + , + container + ); + const cursorSpotAfter = createCursorSpot(ELEMENT_TYPES.CURSOR_SPOT_AFTER); + + wrapper.appendChild(cursorSpotBefore); + wrapper.appendChild(container); + wrapper.appendChild(cursorSpotAfter); + + return wrapper; +} + +// MAIN RENDERING + +export function renderTokensToDOM( + tokens: readonly PromptInputProps.InputToken[], + targetElement: HTMLElement, + reactContainers: Set, + options?: { + disabled?: boolean; + readOnly?: boolean; + } +): { + newTriggerElement: HTMLElement | null; + lastReferenceWithZwnj: HTMLElement | null; +} { + const { disabled = false, readOnly = false } = options || {}; + const existingContainers = new Map(); + reactContainers.forEach(container => { + const instanceId = container.getAttribute('data-id'); + if (instanceId && container.isConnected) { + existingContainers.set(instanceId, container); + } else if (container.isConnected) { + unmountComponent(container); + } + }); + reactContainers.clear(); + + // Track existing trigger elements to reuse them + const existingTriggers = new Map(); + findElements(targetElement, { tokenType: ELEMENT_TYPES.TRIGGER }).forEach(el => { + const id = el.getAttribute('data-id'); + if (id) { + existingTriggers.set(id, el); + } + }); + + const existingParagraphs = findAllParagraphs(targetElement); + const paragraphGroups = groupTokensIntoParagraphs(tokens); + + let newTriggerElement: HTMLElement | null = null; + let lastReferenceWithZwnj: HTMLElement | null = null; + + for (let pIndex = 0; pIndex < paragraphGroups.length; pIndex++) { + const paragraphGroup = paragraphGroups[pIndex]; + let p: HTMLParagraphElement; + + if (pIndex < existingParagraphs.length) { + p = existingParagraphs[pIndex]; + // Don't clear innerHTML - we'll do selective updates below + } else { + p = createParagraph(); + targetElement.appendChild(p); + } + + // Build new content for this paragraph + const newNodes: Node[] = []; + + for (let i = 0; i < paragraphGroup.tokens.length; i++) { + const token = paragraphGroup.tokens[i]; + + if (isTextToken(token)) { + if (token.value) { + newNodes.push(document.createTextNode(token.value)); + } + } else if (isTriggerToken(token)) { + let span: HTMLElement; + const isNewTrigger = !token.id || !existingTriggers.has(token.id); + + if (token.id && existingTriggers.has(token.id)) { + // Reuse existing trigger element and update its content + span = existingTriggers.get(token.id)!; + span.textContent = token.triggerChar + token.value; + existingTriggers.delete(token.id); + } else { + // Create new trigger element + span = document.createElement('span'); + span.setAttribute('data-type', ELEMENT_TYPES.TRIGGER); + if (token.id) { + span.setAttribute('data-id', token.id); + } + span.textContent = token.triggerChar + token.value; + } + + newNodes.push(span); + + if (isNewTrigger) { + newTriggerElement = span; + } + } else if (isReferenceToken(token)) { + const existingWrapper = token.id ? existingContainers.get(token.id) : undefined; + if (existingWrapper) { + const tokenType = getTokenType(existingWrapper); + if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { + // Reuse existing wrapper - token props never change + newNodes.push(existingWrapper); + reactContainers.add(existingWrapper); + existingContainers.delete(token.id!); + lastReferenceWithZwnj = existingWrapper; + continue; + } + } + + const wrapper = createReferenceWithCursorSpots(token, reactContainers, disabled, readOnly); + newNodes.push(wrapper); + lastReferenceWithZwnj = wrapper; + } + } + + if (newNodes.length === 0) { + newNodes.push(createTrailingBreak()); + } + + // Efficiently update paragraph children by comparing with existing nodes + const existingNodes = Array.from(p.childNodes); + + // Remove nodes that are no longer needed + for (let i = newNodes.length; i < existingNodes.length; i++) { + existingNodes[i].remove(); + } + + // Update or append nodes + for (let i = 0; i < newNodes.length; i++) { + const newNode = newNodes[i]; + const existingNode = existingNodes[i]; + + if (existingNode === newNode) { + // Node is already in the right position, skip + continue; + } + + if (existingNode) { + // Replace existing node with new node + p.replaceChild(newNode, existingNode); + } else { + // Append new node + p.appendChild(newNode); + } + } + } + + while (targetElement.children.length > paragraphGroups.length) { + targetElement.removeChild(targetElement.lastChild!); + } + + existingContainers.forEach(container => { + if (container.isConnected) { + unmountComponent(container); + } + }); + + normalizeParagraphsAfterRender(targetElement); + + // Cursor restoration is handled by the unified system in use-editable-tokens + + return { newTriggerElement, lastReferenceWithZwnj }; +} diff --git a/src/prompt-input/core/type-guards.ts b/src/prompt-input/core/type-guards.ts new file mode 100644 index 0000000000..4fd9699a66 --- /dev/null +++ b/src/prompt-input/core/type-guards.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; + +// DOM TYPE GUARDS + +export function isHTMLElement(node: Node | null | undefined): node is HTMLElement { + return node?.nodeType === Node.ELEMENT_NODE; +} + +export function isTextNode(node: Node | null): node is Text { + return node?.nodeType === Node.TEXT_NODE; +} + +/** + * Type guard to check if a node is a BR element, optionally with a specific data-id + * @param node The node to check + * @param dataId Optional data-id to match (e.g., ELEMENT_TYPES.TRAILING_BREAK) + * @returns True if the node is a BR element (and matches the data-id if provided) + */ +export function isBRElement(node: Node | null | undefined, dataId?: string): node is HTMLBRElement { + if (node?.nodeName !== 'BR' || !isHTMLElement(node)) { + return false; + } + if (dataId !== undefined) { + return node.getAttribute('data-id') === dataId; + } + return true; +} + +// TOKEN TYPE GUARDS + +export function isTextToken(token: PromptInputProps.InputToken): token is PromptInputProps.TextToken { + return token.type === 'text'; +} + +export function isBreakToken(token: PromptInputProps.InputToken): token is PromptInputProps.TextToken { + return token.type === 'break'; +} + +export function isTriggerToken(token: PromptInputProps.InputToken): token is PromptInputProps.TriggerToken { + return token.type === 'trigger'; +} + +export function isReferenceToken(token: PromptInputProps.InputToken): token is PromptInputProps.ReferenceToken { + return token.type === 'reference'; +} + +export function isPinnedReferenceToken(token: PromptInputProps.InputToken): token is PromptInputProps.ReferenceToken { + return isReferenceToken(token) && token.pinned === true; +} diff --git a/src/prompt-input/core/utils.ts b/src/prompt-input/core/utils.ts new file mode 100644 index 0000000000..b21ca20ab8 --- /dev/null +++ b/src/prompt-input/core/utils.ts @@ -0,0 +1,195 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ELEMENT_TYPES } from './constants'; + +import styles from '../styles.css.js'; + +// TOKEN TYPE UTILITIES + +/** + * Gets the token type from an element's data-type attribute. + * @param element The element to check + * @returns The token type string, or null if not set + */ +export function getTokenType(element: HTMLElement): string | null { + return element.getAttribute('data-type'); +} + +/** + * Inserts a node after a reference node + */ +export function insertAfter(newNode: Node, referenceNode: Node): void { + const parent = referenceNode.parentNode; + if (!parent) { + return; + } + + if (referenceNode.nextSibling) { + parent.insertBefore(newNode, referenceNode.nextSibling); + } else { + parent.appendChild(newNode); + } +} + +// DOM CREATION + +export function createParagraph(): HTMLParagraphElement { + const p = document.createElement('p'); + p.className = styles.paragraph || 'paragraph'; + p.setAttribute('data-paragraph-id', generateTokenId('p')); + return p; +} + +export function createTrailingBreak(): HTMLBRElement { + const br = document.createElement('br'); + br.setAttribute('data-id', ELEMENT_TYPES.TRAILING_BREAK); + return br; +} + +// DOM STATE MANAGEMENT + +export function ensureEmptyState(element: HTMLElement): void { + element.innerHTML = ''; + const p = createParagraph(); + p.appendChild(createTrailingBreak()); + element.appendChild(p); +} + +export function isElementEffectivelyEmpty(element: HTMLElement): boolean { + if (element.childNodes.length === 0) { + return true; + } + + for (const child of Array.from(element.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + if (child.textContent && child.textContent.trim() !== '') { + return false; + } + } else { + return false; + } + } + return true; +} + +// SELECTION UTILITIES + +export function getCurrentSelection(): Selection | null { + return window.getSelection(); +} + +export function getFirstRange(): Range | null { + const selection = getCurrentSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + return selection.getRangeAt(0); +} + +export function selectAllContent(element: HTMLElement): void { + const selection = getCurrentSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(element); + + selection.removeAllRanges(); + selection.addRange(range); +} + +// ID GENERATION + +/** + * Generates a unique ID for tokens (triggers, references, etc.). + * @param prefix The prefix for the ID (e.g., 'trigger', 'reference', 'p') + * @returns A unique ID based on timestamp + */ +export function generateTokenId(prefix: string): string { + return `${prefix}-${Date.now()}`; +} + +// DOM QUERY UTILITIES + +interface TokenQueryOptions { + tokenType?: string | string[]; + tokenId?: string; +} + +/** + * Build a CSS selector from query options + * @param options Query options (tokenType, tokenId) + * @returns CSS selector string, or empty string if no options provided + */ +function buildTokenSelector(options: TokenQueryOptions): string { + const { tokenType, tokenId } = options; + + let selector = ''; + + if (tokenType) { + const types = Array.isArray(tokenType) ? tokenType : [tokenType]; + selector = types.map(type => `[data-type="${type}"]`).join(', '); + } + + if (tokenId) { + selector += `[data-id="${tokenId}"]`; + } + + return selector; +} + +/** + * Find all elements matching the query options + * @param container The container element to search within + * @param options Query options (tokenType, tokenId) + * @returns Array of matching elements + * + * @example + * // Find all triggers + * findElements(container, { tokenType: ELEMENT_TYPES.TRIGGER }) + * + * // Find all cursor spots (before and after) + * findElements(container, { tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER] }) + * + * // Find reference wrappers by token ID + * findElements(container, { tokenType: ELEMENT_TYPES.REFERENCE, tokenId: 'ref-123' }) + * + * // Find trigger by ID + * findElements(container, { tokenType: ELEMENT_TYPES.TRIGGER, tokenId: 'trigger-123' }) + */ +export function findElements(container: HTMLElement, options: TokenQueryOptions): HTMLElement[] { + const selector = buildTokenSelector(options); + return selector ? Array.from(container.querySelectorAll(selector)) : []; +} + +/** + * Find first element matching the query options + * @param container The container element to search within + * @param options Query options (tokenType, tokenId) + * @returns The first matching element, or null if not found + * + * @example + * // Find first trigger + * findElement(container, { tokenType: ELEMENT_TYPES.TRIGGER }) + * + * // Find reference or pinned token in wrapper + * findElement(wrapper, { tokenType: [ELEMENT_TYPES.REFERENCE, ELEMENT_TYPES.PINNED] }) + * + * // Find cursor spot before + * findElement(wrapper, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }) + */ +export function findElement(container: HTMLElement, options: TokenQueryOptions): HTMLElement | null { + const selector = buildTokenSelector(options); + return selector ? container.querySelector(selector) : null; +} + +/** + * Find all paragraph elements in the container + * @param container The container element to search within + * @returns Array of all paragraph elements + */ +export function findAllParagraphs(container: HTMLElement): HTMLParagraphElement[] { + return Array.from(container.querySelectorAll('p')); +} diff --git a/src/prompt-input/index.tsx b/src/prompt-input/index.tsx index af9857f233..462ffcaa66 100644 --- a/src/prompt-input/index.tsx +++ b/src/prompt-input/index.tsx @@ -24,7 +24,7 @@ const PromptInput = React.forwardRef( maxRows = 3, ...props }: PromptInputProps, - ref: React.Ref + ref: React.Ref ) => { const baseComponentProps = useBaseComponent('PromptInput', { props: { diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index 19dab2560e..6fbdb44843 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -1,15 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + import { IconProps } from '../icon/interfaces'; -import { - BaseChangeDetail, - BaseInputProps, - InputAutoComplete, - InputAutoCorrect, - InputKeyEvents, - InputSpellcheck, -} from '../input/interfaces'; +import { BaseInputProps, InputAutoCorrect, InputKeyEvents, InputSpellcheck } from '../input/interfaces'; import { BaseComponentProps } from '../internal/base-component'; +import { BaseDropdownHostProps, OptionsFilteringType } from '../internal/components/dropdown/interfaces'; +import { DropdownStatusProps } from '../internal/components/dropdown-status'; +import { OptionDefinition, OptionGroup } from '../internal/components/option/interfaces'; import { FormFieldValidationControlProps } from '../internal/context/form-field-context'; import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; /** @@ -18,28 +16,97 @@ import { BaseKeyDetail, NonCancelableEventHandler } from '../internal/events'; import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface PromptInputProps - extends Omit, + extends Omit, InputKeyEvents, InputAutoCorrect, - InputAutoComplete, InputSpellcheck, BaseComponentProps, FormFieldValidationControlProps { + /** + * Specifies the name of the prompt input for form submissions. + */ + name?: string; + + /** + * Specifies whether to enable a browser's autocomplete functionality for this input. + * In some cases it might be appropriate to disable autocomplete (for example, for security-sensitive fields). + * To use it correctly, set the `name` property. + * + * You can either provide a boolean value to set the property to "on" or "off", or specify a string value + * for the [autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute. + * + * Note: When `menus` is defined, autocomplete will not function. + */ + autoComplete?: boolean | string; + + /** + * Specifies the content of the prompt input. + * + * When `menus` is defined (token mode): + * - This property is optional and defaults to empty string + * - The actual content is managed via the `tokens` array + * - `onChange` and `onAction` events will provide the serialized text value + * + * When `menus` is not defined (text mode): + * - This property is required + * - Represents the current text content of the textarea + */ + value?: string; + /** + * Specifies the content of the prompt input when using token mode. + * + * All tokens use the same unified structure with a `value` property: + * - Text tokens: `value` contains the text content + * - Reference tokens: `value` contains the reference value, `label` for display (e.g., '@john') + * - Trigger tokens: `value` contains the filter text, `triggerChar` for the trigger character + * + * When `menus` is defined, you should use `tokens` to control the content instead of `value`. + */ + tokens?: readonly PromptInputProps.InputToken[]; + + /** + * Custom function to transform tokens into plain text for the `value` field in `onChange` and `onAction` events + * and for the hidden input when `name` is specified. + * + * If not provided, the default implementation is: + * ``` + * tokens.map(token => token.value).join(''); + * ``` + * + * Use this to customize serialization, for example: + * - Using `label` instead of `value` for reference tokens + * - Adding custom formatting or separators between tokens + */ + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + + /** + * Called whenever a user changes the input value (by typing or pasting). + * The event `detail` contains the current value as a string and an array of tokens. + * + * When `menus` is defined, the `value` is derived from `tokensToText(tokens)` if provided, otherwise from the default token-to-text conversion. + */ + onChange?: NonCancelableEventHandler; + /** * Called whenever a user clicks the action button or presses the "Enter" key. - * The event `detail` contains the current value of the field. + * The event `detail` contains the current value as a string and an array of tokens. + * + * When `menus` is defined, the `value` is derived from `tokensToText(tokens)` if provided, otherwise from the default token-to-text conversion. */ onAction?: NonCancelableEventHandler; + /** * Determines what icon to display in the action button. */ actionButtonIconName?: IconProps.Name; + /** * Specifies the URL of a custom icon. Use this property if the icon you want isn't available. * * If you set both `actionButtonIconUrl` and `actionButtonIconSvg`, `actionButtonIconSvg` will take precedence. */ actionButtonIconUrl?: string; + /** * Specifies the SVG of a custom icon. * @@ -62,14 +129,17 @@ export interface PromptInputProps * In most cases, they aren't needed, as the `svg` element inherits styles from the icon component. */ actionButtonIconSvg?: React.ReactNode; + /** * Specifies alternate text for a custom icon. We recommend that you provide this for accessibility. * This property is ignored if you use a predefined icon or if you set your custom icon using the `iconSvg` slot. */ actionButtonIconAlt?: string; + /** * Adds an aria-label to the action button. * @i18n + * @deprecated Use `i18nStrings.actionButtonAriaLabel` instead. */ actionButtonAriaLabel?: string; @@ -118,6 +188,65 @@ export interface PromptInputProps */ disableSecondaryContentPaddings?: boolean; + /** + * Menus that can be triggered via specific symbols (e.g., "/" or "@"). + * For menus only relevant to triggers at the start of the input, set `useAtStart: true`, defaults to `false`. + */ + menus?: PromptInputProps.MenuDefinition[]; + + /** + * Maximum height of the menu dropdown in pixels. + * When not specified, the menu will grow to fit its content. + */ + maxMenuHeight?: number; + + /** + * Called whenever a user selects an option in a menu. + */ + onMenuItemSelect?: NonCancelableEventHandler; + + /** + * Use this event to implement the asynchronous behavior for menus. + * + * The event is called in the following situations: + * - The user scrolls to the end of the list of options, if `statusType` is set to `pending` (pagination). + * - The user clicks on the recovery button in the error state. + * - The user types after the trigger character. + * - The menu is opened. + * + * The detail object contains the following properties: + * - `menuId` - The ID of the menu that triggered the event. + * - `filteringText` - The value to use to fetch options (undefined for pagination). + * - `firstPage` - Indicates that you should fetch the first page of options. + * - `samePage` - Indicates that you should fetch the same page (for example, when clicking recovery button). + */ + onMenuLoadItems?: NonCancelableEventHandler; + + /** + * Called when the user types to filter options in manual filtering mode for a menu. + * Use this to filter the options based on the filtering text. + * + * The detail object contains: + * - `menuId` - The ID of the menu that triggered the event. + * - `filteringText` - The text to use for filtering options. + */ + onMenuFilter?: NonCancelableEventHandler; + + /** + * An object containing all the localized strings required by the component. + * + * - `ariaLabel` (string) - Adds an aria-label to the input element. + * - `actionButtonAriaLabel` (string) - Adds an aria-label to the action button. + * - `menuErrorIconAriaLabel` (string) - Provides a text alternative for the error icon in the error message in menus. + * - `menuRecoveryText` (string) - Specifies the text for the recovery button in menus. The text is displayed next to the error text. + * - `menuLoadingText` (string) - Specifies the text to display when menus are in a loading state. + * - `menuFinishedText` (string) - Specifies the text to display when menus have finished loading all items. + * - `menuErrorText` (string) - Specifies the text to display when menus encounter an error while loading. + * - `selectedMenuItemAriaLabel` (string) - Specifies the localized string that describes an option as being selected. + * @i18n + */ + i18nStrings?: PromptInputProps.I18nStrings; + /** * Attributes to add to the native `textarea` element. * Some attributes will be automatically combined with internal attribute values: @@ -125,14 +254,13 @@ export interface PromptInputProps * - Event handlers will be chained, unless the default is prevented. * * We do not support using this attribute to apply custom styling. + * If `tokens` is defined, nativeTextareaAttributes will be ignored. * * @awsuiSystem core */ nativeTextareaAttributes?: NativeAttributes>; /** - * An object containing CSS properties to customize the prompt input's visual appearance. - * Refer to the [style](/components/prompt-input/?tabId=style) tab for more details. * @awsuiSystem core */ style?: PromptInputProps.Style; @@ -140,7 +268,180 @@ export interface PromptInputProps export namespace PromptInputProps { export type KeyDetail = BaseKeyDetail; - export type ActionDetail = BaseChangeDetail; + + export interface I18nStrings { + actionButtonAriaLabel?: string; + menuErrorIconAriaLabel?: string; + menuRecoveryText?: string; + menuLoadingText?: string; + menuFinishedText?: string; + menuErrorText?: string; + /** + * Aria label announced when a reference token is inserted from a menu. + * Receives the token object with label and value properties. + * @param token The inserted token + * @returns The announcement string + * @default `${token.label || token.value} inserted` + */ + tokenInsertedAriaLabel?: (token: { label?: string; value: string }) => string; + /** + * Aria label announced when a reference token is pinned (inserted at the start). + * Receives the token object with label and value properties. + * @param token The pinned token + * @returns The announcement string + * @default `${token.label || token.value} pinned` + */ + tokenPinnedAriaLabel?: (token: { label?: string; value: string }) => string; + /** + * Aria label announced when a reference token is removed. + * Receives the token object with label and value properties. + * @param token The removed token + * @returns The announcement string + * @default `${token.label || token.value} removed` + */ + tokenRemovedAriaLabel?: (token: { label?: string; value: string }) => string; + } + + export interface TextToken { + type: 'text' | 'break'; + value: string; + } + + export interface ReferenceToken { + type: 'reference'; + id: string; + label: string; + value: string; + menuId: string; + /** + * When true, prevents user entered text from being placed before this token. + * Typically set for reference tokens from useAtStart menus. + */ + pinned?: boolean; + } + + /** + * Token type for active menu triggers with filter text. + * Represents a trigger character (e.g., "@" or "/") followed by filtering text. + * This token type is automatically managed by the component when menus are active. + * + * - `value`: The filtering text (without the trigger character) + * - `triggerChar`: The trigger character that opened the menu + */ + export interface TriggerToken { + type: 'trigger'; + value: string; + triggerChar: string; + /** + * Internal: Unique ID for this specific trigger token instance. + * Used to anchor menus to the correct trigger when multiple triggers exist. + */ + id?: string; + } + + export type InputToken = TextToken | ReferenceToken | TriggerToken; + + export interface ChangeDetail { + value: string; + tokens: InputToken[]; + } + + export interface ActionDetail { + value: string; + tokens: InputToken[]; + } + + export interface MenuItemSelectDetail { + menuId: string; + option: OptionDefinition; + } + + export interface MenuLoadItemsDetail { + menuId: string; + filteringText?: string; // Optional - undefined for pagination (load more) + firstPage: boolean; + samePage: boolean; + } + + export interface MenuFilterDetail { + menuId: string; + filteringText: string; + } + + export interface MenuDefinition + extends Pick, + Pick { + /** + * The unique identifier for this menu. + */ + id: string; + + /** + * The unique trigger symbol for showing this menu. + */ + trigger: string; + + /** + * Set `useAtStart=true` for menus where a trigger should only be detected at the start of input. + * Set this for menus designated to modes or actions. + * + * Menus with `useAtStart=true` create pinned reference tokens. + */ + useAtStart?: boolean; + + /** + * Specifies an array of options that are displayed to the user as a list. + * The options can be grouped using `OptionGroup` objects. + * + * #### Option + * - `value` (string) - The returned value of the option when selected. + * - `label` (string) - (Optional) Option text displayed to the user. + * - `lang` (string) - (Optional) The language of the option, provided as a BCP 47 language tag. + * - `description` (string) - (Optional) Further information about the option that appears below the label. + * - `disabled` (boolean) - (Optional) Determines whether the option is disabled. + * - `labelTag` (string) - (Optional) A label tag that provides additional guidance, shown next to the label. + * - `tags` [string[]] - (Optional) A list of tags giving further guidance about the option. + * - `filteringTags` [string[]] - (Optional) A list of additional tags used for automatic filtering. + * - `iconName` (string) - (Optional) Specifies the name of an [icon](/components/icon/) to display in the option. + * - `iconAriaLabel` (string) - (Optional) Specifies alternate text for the icon. We recommend that you provide this for accessibility. + * - `iconAlt` (string) - (Optional) **Deprecated**, replaced by \`iconAriaLabel\`. Specifies alternate text for a custom icon, for use with `iconUrl`. + * - `iconUrl` (string) - (Optional) URL of a custom icon. + * - `iconSvg` (ReactNode) - (Optional) Custom SVG icon. Equivalent to the `svg` slot of the [icon component](/components/icon/). + * + * #### OptionGroup + * - `label` (string) - Option group text displayed to the user. + * - `disabled` (boolean) - (Optional) Determines whether the option group is disabled. + * - `options` (Option[]) - (Optional) The options under this group. + * + * Note: Only one level of option nesting is supported. + * + * If you want to use the built-in filtering capabilities of this component, provide + * a list of all valid options here and they will be automatically filtered based on the user's filtering input. + * + * Alternatively, you can listen to the `onChange` or `onLoadItems` event and set new options + * on your own. + */ + options: (OptionDefinition | OptionGroup)[]; + + /** + * Determines how filtering is applied to the list of `options`: + * + * - `auto` - The component will automatically filter options based on user input. + * - `manual` - You will set up `onMenuFilter` event listeners and filter options on your side or request + * them from server. + * + * By default the component will filter the provided `options` based on the value of the filtering input field. + * Only options that have a `value`, `label`, `description` or `labelTag` that contains the input value as a substring + * are displayed in the list of options. + * + * If you set this property to `manual`, this default filtering mechanism is disabled and all provided `options` are + * displayed in the menu. In that case make sure that you use the `onMenuFilter` event in order + * to set the `options` property to the options that are relevant for the user, given the filtering input value. + * + * Note: Manual filtering doesn't disable match highlighting. + **/ + filteringType?: Exclude; + } export interface Ref { /** @@ -161,6 +462,15 @@ export namespace PromptInputProps { * common pitfalls: https://stackoverflow.com/questions/60129605/is-javascripts-setselectionrange-incompatible-with-react-hooks */ setSelectionRange(start: number | null, end: number | null, direction?: 'forward' | 'backward' | 'none'): void; + + /** + * Inserts text at a specified position. Triggers input events and menu detection when `menus` is defined. + * + * @param text The text to insert. + * @param cursorStart Position to insert at. Defaults to end of content. + * @param cursorEnd Cursor position after insertion. Defaults to end of inserted text. + */ + insertText(text: string, cursorStart?: number, cursorEnd?: number): void; } export interface Style { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index aa76b69f98..be82557bee 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -1,22 +1,50 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { Ref, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import React, { Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; import clsx from 'clsx'; -import { useDensityMode } from '@cloudscape-design/component-toolkit/internal'; +import { useDensityMode, useStableCallback, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import InternalButton from '../button/internal'; import { convertAutoComplete } from '../input/utils'; import { getBaseProps } from '../internal/base-component'; +import { useDropdownStatus } from '../internal/components/dropdown-status'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; -import * as tokens from '../internal/generated/styles/tokens'; +import * as designTokens from '../internal/generated/styles/tokens'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { SomeRequired } from '../internal/types'; -import WithNativeAttributes from '../internal/utils/with-native-attributes'; +import InternalLiveRegion from '../live-region/internal'; +import TextareaMode from './components/textarea-mode'; +import TokenMode from './components/token-mode'; +import { CURSOR_DETECTION_DELAY, DEFAULT_MAX_ROWS, NEXT_TICK_TIMEOUT } from './core/constants'; +import { isCursorInTriggerToken, setCursorPosition, setCursorRange } from './core/cursor-manager'; +import { + createCursorNormalizationHandler, + createKeyboardHandlers, + createSelectionNormalizationHandler, + handleSpaceAfterClosedTrigger, +} from './core/event-handlers'; +import { createEditableState } from './core/event-handlers'; +import { + handleArrowKeyNavigation, + handleBackspaceAtParagraphStart, + handleDeleteAtParagraphEnd, + handleReferenceTokenDeletion, + splitParagraphAtCursor, +} from './core/event-handlers'; +import { MenuItem, useMenuItems } from './core/menu-state'; +import { useMenuLoadMore } from './core/menu-state'; +import { handleMenuSelection } from './core/token-engine'; +import { getPromptText } from './core/token-extractor'; +import { selectAllContent } from './core/utils'; import { PromptInputProps } from './interfaces'; +import { useShortcuts } from './shortcuts/use-shortcuts'; import { getPromptInputStyles } from './styles'; +import { useEditableTokens } from './tokens/use-editable-tokens'; +import { insertTextIntoContentEditable } from './utils/insert-text-content-editable'; import styles from './styles.css.js'; import testutilStyles from './test-classes/styles.css.js'; @@ -28,15 +56,15 @@ interface InternalPromptInputProps const InternalPromptInput = React.forwardRef( ( { - value, + value: valueProp, actionButtonAriaLabel, actionButtonIconName, actionButtonIconUrl, actionButtonIconSvg, actionButtonIconAlt, ariaLabel, - autoComplete, autoFocus, + autoComplete, disableActionButton, disableBrowserAutocorrect, disabled, @@ -59,41 +87,259 @@ const InternalPromptInput = React.forwardRef( disableSecondaryContentPaddings, nativeTextareaAttributes, style, + tokens, + tokensToText, + menus, + maxMenuHeight, + onMenuItemSelect, + onMenuFilter, + onMenuLoadItems, + i18nStrings, __internalRootRef, ...rest }: InternalPromptInputProps, - ref: Ref + ref: Ref ) => { const { ariaLabelledby, ariaDescribedby, controlId, invalid, warning } = useFormFieldContext(rest); - const baseProps = getBaseProps(rest); + // i18n strings with fallback to deprecated properties + const effectiveActionButtonAriaLabel = i18nStrings?.actionButtonAriaLabel ?? actionButtonAriaLabel; + + // Mode detection - must be declared before useEffect hooks that use it + const isTokenMode = !!menus; + + // Default value based on mode + const value = valueProp ?? (isTokenMode ? '' : ''); + + // Refs const textareaRef = useRef(null); + const editableElementRef = useRef(null); + const reactContainersRef = useRef>(new Set()); + const lastKnownCursorPositionRef = useRef(0); + + // Initialize consolidated shortcuts system + const shortcuts = useShortcuts({ + isTokenMode, + tokens, + menus, + tokensToText, + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => { + fireNonCancelableEvent(onChange, detail); + }, + editableElementRef, + }); + // Extract shortcuts state for easier access + const { + ignoreCursorDetection, + activeTriggerToken, + activeMenu, + menuIsOpen, + menuFilterText, + triggerWrapperRef, + triggerWrapperReady, + processUserInput, + markTokensAsSent, + setUpdateSource, + } = shortcuts; + + // Mode detection const isRefresh = useVisualRefresh(); - const isCompactMode = useDensityMode(textareaRef) === 'compact'; + useDensityMode(textareaRef); + useDensityMode(editableElementRef); + + // Style constants + const PADDING = isRefresh ? designTokens.spaceXxs : designTokens.spaceXxxs; + const LINE_HEIGHT = designTokens.lineHeightBodyM; - const PADDING = isRefresh ? tokens.spaceXxs : tokens.spaceXxxs; - const LINE_HEIGHT = tokens.lineHeightBodyM; - const DEFAULT_MAX_ROWS = 3; + // Helper to get the active input element + const getActiveElement = useStableCallback(() => { + return isTokenMode ? editableElementRef.current : textareaRef.current; + }); + + // Create editable state for coordinating between event handlers and input processing + const editableState = useMemo(() => createEditableState(), []); useImperativeHandle( ref, () => ({ focus(...args: Parameters) { - textareaRef.current?.focus(...args); + getActiveElement()?.focus(...args); }, select() { - textareaRef.current?.select(); + if (isTokenMode) { + if (editableElementRef.current) { + selectAllContent(editableElementRef.current); + } + } else { + textareaRef.current?.select(); + } }, setSelectionRange(...args: Parameters) { - textareaRef.current?.setSelectionRange(...args); + if (isTokenMode && editableElementRef.current) { + const [start, end] = args; + + if (end !== undefined && end !== null && end !== start) { + setCursorRange(editableElementRef.current, start ?? 0, end); + } else { + setCursorPosition(editableElementRef.current, start ?? 0); + } + } else { + textareaRef.current?.setSelectionRange(...args); + } + }, + insertText(text: string, cursorStart?: number, cursorEnd?: number) { + // Guard against disabled/readonly at the ref level + if (disabled || readOnly) { + return; + } + + if (isTokenMode) { + if (!editableElementRef.current || !tokens || !menus) { + return; + } + + insertTextIntoContentEditable( + editableElementRef.current, + text, + cursorStart, + cursorEnd, + tokens, + menus, + detail => fireNonCancelableEvent(onChange, detail), + tokensToText ?? getPromptText, + lastKnownCursorPositionRef.current, + lastKnownCursorPositionRef + ); + } else { + // Textarea mode + if (!textareaRef.current) { + return; + } + + const textarea = textareaRef.current; + textarea.focus(); + + const currentValue = textarea.value; + const insertPosition = cursorStart ?? textarea.selectionStart ?? 0; + const newValue = currentValue.substring(0, insertPosition) + text + currentValue.substring(insertPosition); + + textarea.value = newValue; + + const finalCursorPosition = cursorEnd ?? insertPosition + text.length; + textarea.setSelectionRange(finalCursorPosition, finalCursorPosition); + + textarea.dispatchEvent(new Event('input', { bubbles: true })); + fireNonCancelableEvent(onChange, { + value: newValue, + tokens: [], + }); + } }, }), - [textareaRef] + [getActiveElement, isTokenMode, disabled, readOnly, tokens, menus, onChange, tokensToText] ); - const handleKeyDown = (event: React.KeyboardEvent) => { + /** + * Dynamically adjusts the input height based on content and row constraints. + */ + const adjustInputHeight = useStableCallback(() => { + const element = getActiveElement(); + if (!element) { + return; + } + + // Preserve scroll position for token mode + const scrollTop = element.scrollTop; + element.style.height = 'auto'; + + const minRowsHeight = isTokenMode + ? `calc(${minRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})` + : `calc(${LINE_HEIGHT} + ${designTokens.spaceScaledXxs} * 2)`; + const scrollHeight = `calc(${element.scrollHeight}px)`; + + if (maxRows === -1) { + element.style.height = `max(${scrollHeight}, ${minRowsHeight})`; + } else { + const effectiveMaxRows = maxRows <= 0 ? DEFAULT_MAX_ROWS : maxRows; + const maxRowsHeight = `calc(${effectiveMaxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; + element.style.height = `min(max(${scrollHeight}, ${minRowsHeight}), ${maxRowsHeight})`; + } + + if (isTokenMode) { + element.scrollTop = scrollTop; + } + }); + + // Adjust height when tokens change (after DOM updates) + useEffect(() => { + if (isTokenMode) { + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => adjustInputHeight()); + } + }, [isTokenMode, tokens, adjustInputHeight]); + + // Helper to get plain text value from tokens or value prop + const getPlainTextValue = useStableCallback(() => { + if (isTokenMode) { + return tokensToText ? tokensToText(tokens ?? []) : getPromptText(tokens ?? []); + } + return value; + }); + + // Use the editable hook as interface layer between contentEditable DOM and React + const { handleInput: handleInputBase } = useEditableTokens({ + elementRef: editableElementRef, + reactContainersRef, + tokens, + menus, + tokensToText, + onChange: detail => { + processUserInput(detail.tokens); + }, + adjustInputHeight, + disabled: disabled || !isTokenMode, + readOnly, + editableState, + ignoreCursorDetection, + lastKnownCursorPositionRef, + }); + + const handleInput = handleInputBase; + + // Track if we're in the middle of arrow key navigation to avoid cursor trapping + const skipNormalizationRef = React.useRef(false); + + // Normalize cursor position: if cursor is right after a wrapper, move it into the cursor spot + React.useEffect(() => { + if (!isTokenMode || !editableElementRef.current) { + return; + } + + const normalizeCursorPosition = createCursorNormalizationHandler( + editableElementRef, + skipNormalizationRef, + editableState + ); + + document.addEventListener('selectionchange', normalizeCursorPosition); + return () => document.removeEventListener('selectionchange', normalizeCursorPosition); + }, [isTokenMode, editableState]); + + // Normalize selection to include entire reference tokens when boundary is in cursor spots + React.useEffect(() => { + if (!isTokenMode) { + return; + } + + const normalizeSelection = createSelectionNormalizationHandler(); + + document.addEventListener('selectionchange', normalizeSelection); + return () => document.removeEventListener('selectionchange', normalizeSelection); + }, [isTokenMode]); + + const handleTextareaKeyDown = (event: React.KeyboardEvent) => { fireKeyboardEvent(onKeyDown, event); if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { @@ -101,52 +347,337 @@ const InternalPromptInput = React.forwardRef( event.currentTarget.form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value }); + fireNonCancelableEvent(onAction, { value: getPlainTextValue(), tokens: [...(tokens ?? [])] }); } }; - const handleChange = (event: React.ChangeEvent) => { - fireNonCancelableEvent(onChange, { value: event.target.value }); - adjustTextareaHeight(); + const handleTextareaChange = (event: React.ChangeEvent) => { + const newTokens = isTokenMode ? [...(tokens ?? [])] : []; + markTokensAsSent(newTokens); + fireNonCancelableEvent(onChange, { + value: event.target.value, + tokens: newTokens, + }); + adjustInputHeight(); }; - const hasActionButton = actionButtonIconName || actionButtonIconSvg || actionButtonIconUrl || customPrimaryAction; + // Keyboard handler for contentEditable + const handleEditableElementKeyDown = useStableCallback((event: React.KeyboardEvent) => { + // Handle arrow key navigation to skip ZWNJ in cursor spots + if (handleArrowKeyNavigation(event, skipNormalizationRef)) { + return; + } + + if (event.key === 'Enter' && event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); - const adjustTextareaHeight = useCallback(() => { - if (textareaRef.current) { - // this is required so the scrollHeight becomes dynamic, otherwise it will be locked at the highest value for the size it reached e.g. 500px - textareaRef.current.style.height = 'auto'; + // Block action if cursor is inside a trigger token + if (editableElementRef.current && isCursorInTriggerToken(editableElementRef.current)) { + return; + } - const minTextareaHeight = `calc(${LINE_HEIGHT} + ${tokens.spaceScaledXxs} * 2)`; // the min height of Textarea with 1 row + if (editableElementRef.current) { + splitParagraphAtCursor(editableElementRef.current, editableState); + } + return; + } - if (maxRows === -1) { - const scrollHeight = `calc(${textareaRef.current.scrollHeight}px)`; - textareaRef.current.style.height = `max(${scrollHeight}, ${minTextareaHeight})`; - } else { - const maxRowsHeight = `calc(${maxRows <= 0 ? DEFAULT_MAX_ROWS : maxRows} * (${LINE_HEIGHT} + ${PADDING} / 2) + ${PADDING})`; - const scrollHeight = `calc(${textareaRef.current.scrollHeight}px)`; - textareaRef.current.style.height = `min(max(${scrollHeight}, ${minTextareaHeight}), ${maxRowsHeight})`; + if (event.key === 'Backspace' || event.key === 'Delete') { + if ( + editableElementRef.current && + handleReferenceTokenDeletion( + event, + event.key === 'Backspace', + editableElementRef.current, + editableState, + (message: string) => { + setTokenOperationAnnouncement(message); + setTimeout(() => setTokenOperationAnnouncement(''), 100); + }, + i18nStrings + ) + ) { + return; } } - }, [maxRows, LINE_HEIGHT, PADDING]); + if (event.key === 'Backspace' && tokens && editableElementRef.current) { + if ( + handleBackspaceAtParagraphStart( + event, + editableElementRef.current, + tokens, + tokensToText, + getPromptText, + (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => { + markTokensAsSent(detail.tokens); + fireNonCancelableEvent(onChange, detail); + }, + setCursorPosition, + editableState + ) + ) { + return; + } + } + + if (event.key === 'Delete' && tokens && editableElementRef.current) { + if ( + handleDeleteAtParagraphEnd( + event, + editableElementRef.current, + tokens, + tokensToText, + getPromptText, + lastKnownCursorPositionRef.current, + (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => { + markTokensAsSent(detail.tokens); + fireNonCancelableEvent(onChange, detail); + }, + setCursorPosition, + editableState + ) + ) { + return; + } + } + + fireKeyboardEvent(onKeyDown, event); + + // Handle space after closed trigger - move space out of trigger element + if ( + event.key === ' ' && + editableElementRef.current && + shortcuts && + handleSpaceAfterClosedTrigger( + event, + editableElementRef.current, + shortcuts.menuIsOpen, + shortcuts.triggerValueWhenClosed, + editableState + ) + ) { + return; + } + + if (keyboardHandlers) { + if (keyboardHandlers.handleMenuNavigation(event)) { + return; + } + } + + if (keyboardHandlers) { + keyboardHandlers.handleEnterKey(event); + } + }); + + const handleEditableElementBlur = useStableCallback(() => { + if (onBlur) { + fireNonCancelableEvent(onBlur); + } + }); + + // Auto-focus on mount (token mode only) useEffect(() => { - const handleResize = () => { - adjustTextareaHeight(); - }; + if (isTokenMode && autoFocus && editableElementRef.current) { + editableElementRef.current.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Lifecycle effects: window resize and cleanup + useEffect(() => { + // Window resize handler + const handleResize = () => adjustInputHeight(); window.addEventListener('resize', handleResize); + // Capture containers ref for cleanup + const containers = reactContainersRef.current; + + // Cleanup on unmount return () => { window.removeEventListener('resize', handleResize); + containers.forEach(container => ReactDOM.unmountComponentAtNode(container)); + containers.clear(); }; - }, [adjustTextareaHeight]); + }, [adjustInputHeight]); + + // Handle menu option selection - replace TriggerToken with selected option + const handleMenuSelect = useStableCallback((option: MenuItem) => { + if (!activeMenu || !activeTriggerToken || !tokens) { + return; + } + + ignoreCursorDetection.current = true; + shortcuts.setCursorInTrigger(false); + setUpdateSource('menu-selection'); + + const result = handleMenuSelection( + tokens, + { + value: option.option.value || '', + label: option.option.label || option.option.value || '', + }, + activeMenu.id, + activeMenu.useAtStart ?? false, + activeTriggerToken + ); + + const value = tokensToText ? tokensToText(result.tokens) : getPromptText(result.tokens); + markTokensAsSent(result.tokens); + + editableState.menuSelectionTokenId = result.insertedToken.id || null; + editableState.menuSelectionIsPinned = activeMenu.useAtStart ?? false; + + const isPinned = activeMenu.useAtStart ?? false; + const tokenLabel = result.insertedToken.label || result.insertedToken.value; + const announcement = isPinned + ? (i18nStrings?.tokenPinnedAriaLabel?.(result.insertedToken) ?? `${tokenLabel} pinned`) + : (i18nStrings?.tokenInsertedAriaLabel?.(result.insertedToken) ?? `${tokenLabel} inserted`); + + setTokenOperationAnnouncement(announcement); + setTimeout(() => setTokenOperationAnnouncement(''), 100); + + fireNonCancelableEvent(onChange, { value, tokens: result.tokens }); + + fireNonCancelableEvent(onMenuItemSelect, { + menuId: activeMenu.id, + option: option.option, + }); + }); + + // Menu items controller - always call hooks + const menuItemsResult = useMenuItems({ + menu: activeMenu ?? { + id: '', + trigger: '', + options: [], + }, + filterText: menuFilterText, + onSelectItem: handleMenuSelect, + }); + + // Menu items state and handlers + const [menuItemsState, menuItemsHandlers] = menuItemsResult; + + // Consolidated menu state ref for keyboard handlers + const menuStateRef = useRef({ + itemsState: menuItemsState, + itemsHandlers: menuItemsHandlers, + isOpen: menuIsOpen, + }); + + // Update ref when state changes + menuStateRef.current = { + itemsState: menuItemsState, + itemsHandlers: menuItemsHandlers, + isOpen: menuIsOpen, + }; + + // Create keyboard handlers + const keyboardHandlers = useMemo(() => { + if (!editableElementRef.current) { + return null; + } + + return createKeyboardHandlers({ + getMenuOpen: () => menuStateRef.current.isOpen, + getMenuItemsState: () => menuStateRef.current.itemsState, + getMenuItemsHandlers: () => menuStateRef.current.itemsHandlers, + onAction: onAction ? detail => fireNonCancelableEvent(onAction, detail) : undefined, + tokensToText, + tokens, + getPromptText, + closeMenu: () => { + ignoreCursorDetection.current = true; + shortcuts.setCursorInTrigger(false); + + setTimeout(() => { + ignoreCursorDetection.current = false; + }, CURSOR_DETECTION_DELAY); + }, + announceTokenOperation: (message: string) => { + setTokenOperationAnnouncement(message); + setTimeout(() => setTokenOperationAnnouncement(''), 100); + }, + i18nStrings, + }); + }, [onAction, tokensToText, tokens, ignoreCursorDetection, shortcuts, i18nStrings]); + + // Menu load more controller + const menuLoadMoreResult = useMenuLoadMore({ + menu: activeMenu ?? { + id: '', + trigger: '', + options: [], + }, + statusType: activeMenu?.statusType ?? 'finished', + onLoadItems: detail => { + fireNonCancelableEvent(onMenuLoadItems, detail); + }, + onLoadMoreItems: () => { + fireNonCancelableEvent(onMenuLoadItems, { + menuId: activeMenu?.id ?? '', + filteringText: undefined, // Pagination - no filter text + firstPage: false, + samePage: false, + }); + }, + }); + + const menuLoadMoreHandlers = activeMenu ? menuLoadMoreResult : null; + + // Menu state management effect + useEffect(() => { + if (menuIsOpen && activeMenu && menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnMenuOpen(); + } + }, [menuIsOpen, activeMenu, menuLoadMoreHandlers]); + + // Highlight first item when menu opens or items change + const prevMenuOpenRef = useRef(false); + const prevItemsLengthRef = useRef(0); + + useEffect(() => { + const justOpened = menuIsOpen && !prevMenuOpenRef.current; + const itemsChanged = menuItemsState && menuItemsState.items.length !== prevItemsLengthRef.current; + + if ( + (justOpened || (menuIsOpen && itemsChanged)) && + menuItemsHandlers && + menuItemsState && + menuItemsState.items.length > 0 + ) { + setTimeout(() => { + menuItemsHandlers?.goHomeWithKeyboard(); + }, NEXT_TICK_TIMEOUT); + } + prevMenuOpenRef.current = menuIsOpen; + prevItemsLengthRef.current = menuItemsState?.items.length ?? 0; + }, [menuIsOpen, menuItemsHandlers, menuItemsState, menuItemsState.items.length]); + + // Fire filter event when trigger token filter text changes useEffect(() => { - adjustTextareaHeight(); - }, [value, adjustTextareaHeight, maxRows, isCompactMode]); + if (activeTriggerToken && activeMenu && onMenuFilter) { + fireNonCancelableEvent(onMenuFilter, { + menuId: activeMenu.id, + filteringText: activeTriggerToken.value, + }); + } + }, [activeTriggerToken, activeMenu, onMenuFilter]); - const attributes: React.TextareaHTMLAttributes = { + const hasActionButton = !!( + actionButtonIconName || + actionButtonIconSvg || + actionButtonIconUrl || + customPrimaryAction + ); + + // Show placeholder in token mode when input is empty + const showPlaceholder = isTokenMode && placeholder && (!tokens || tokens.length === 0); + + const textareaAttributes: React.TextareaHTMLAttributes = { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, @@ -159,37 +690,94 @@ const InternalPromptInput = React.forwardRef( [styles.warning]: warning, }), autoComplete: convertAutoComplete(autoComplete), + autoCorrect: disableBrowserAutocorrect ? 'off' : undefined, + autoCapitalize: disableBrowserAutocorrect ? 'off' : undefined, spellCheck: spellcheck, disabled, readOnly: readOnly ? true : undefined, rows: minRows, - onKeyDown: handleKeyDown, - onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), - // We set a default value on the component in order to force it into the controlled mode. value: value || '', - onChange: handleChange, + onKeyDown: handleTextareaKeyDown, + onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), + onChange: handleTextareaChange, onBlur: onBlur && (() => fireNonCancelableEvent(onBlur)), onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), }; - if (disableBrowserAutocorrect) { - attributes.autoCorrect = 'off'; - attributes.autoCapitalize = 'off'; - } + const editableElementAttributes: React.HTMLAttributes & { + 'data-placeholder'?: string; + } = { + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + 'aria-invalid': invalid ? 'true' : undefined, + 'aria-disabled': disabled ? 'true' : undefined, + 'aria-readonly': readOnly ? 'true' : undefined, + 'aria-required': rest.ariaRequired ? 'true' : undefined, + 'data-placeholder': placeholder, + className: clsx(styles.textarea, testutilStyles.textarea, { + [styles.invalid]: invalid, + [styles.warning]: warning, + [styles['textarea-disabled']]: disabled, + [styles['textarea-readonly']]: readOnly, + [styles['placeholder-visible']]: showPlaceholder, + }), + autoCorrect: disableBrowserAutocorrect ? 'off' : undefined, + autoCapitalize: disableBrowserAutocorrect ? 'off' : undefined, + spellCheck: spellcheck, + tabIndex: disabled ? -1 : 0, + onKeyDown: handleEditableElementKeyDown, + onKeyUp: onKeyUp && (event => fireKeyboardEvent(onKeyUp, event)), + onBlur: handleEditableElementBlur, + onFocus: onFocus && (() => fireNonCancelableEvent(onFocus)), + }; + + // Menu dropdown setup + const menuListId = useUniqueId('menu-list'); + const menuFooterControlId = useUniqueId('menu-footer'); + const highlightedMenuOptionIdSource = useUniqueId(); + const highlightedMenuOptionId = menuItemsState?.highlightedOption ? highlightedMenuOptionIdSource : undefined; + + // Accessibility: Track token operations for screen reader announcements + const [tokenOperationAnnouncement, setTokenOperationAnnouncement] = useState(''); + + // Always call useDropdownStatus hook + const menuDropdownStatusResult = useDropdownStatus({ + ...(activeMenu ?? {}), + isEmpty: !menuItemsState || menuItemsState.items.length === 0, + recoveryText: i18nStrings?.menuRecoveryText, + errorIconAriaLabel: i18nStrings?.menuErrorIconAriaLabel, + onRecoveryClick: () => { + if (menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnRecoveryClick(); + } + editableElementRef.current?.focus(); + }, + hasRecoveryCallback: Boolean(onMenuLoadItems), + }); + + const menuDropdownStatus = activeMenu ? menuDropdownStatusResult : null; + + const shouldRenderMenuDropdown = useMemo( + () => !!(menuIsOpen && activeMenu && menuItemsState), + [menuIsOpen, activeMenu, menuItemsState] + ); - const action = ( + const actionButton = (
{customPrimaryAction ?? ( fireNonCancelableEvent(onAction, { value })} + onClick={() => { + fireNonCancelableEvent(onAction, { value: getPlainTextValue(), tokens: [...(tokens ?? [])] }); + }} variant="icon" /> )} @@ -210,6 +798,10 @@ const InternalPromptInput = React.forwardRef( role="region" style={getPromptInputStyles(style)} > + + {secondaryContent && (
)} +
- - {hasActionButton && !secondaryActions && action} + {isTokenMode ? ( + { + if (menuLoadMoreHandlers) { + menuLoadMoreHandlers.fireLoadMoreOnScroll(); + } + }} + editableElementAttributes={editableElementAttributes} + i18nStrings={i18nStrings} + /> + ) : ( + + )} + {hasActionButton && !secondaryActions && actionButton}
+ {secondaryActions && (
{secondaryActions}
-
textareaRef.current?.focus()} /> - {hasActionButton && action} +
getActiveElement()?.focus()} /> + {hasActionButton && actionButton}
)}
diff --git a/src/prompt-input/shortcuts/use-shortcuts.ts b/src/prompt-input/shortcuts/use-shortcuts.ts new file mode 100644 index 0000000000..b5bdf99a8f --- /dev/null +++ b/src/prompt-input/shortcuts/use-shortcuts.ts @@ -0,0 +1,520 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; +import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; + +import { getFirstScrollableParent } from '../../internal/utils/scrollable-containers'; +import { ELEMENT_TYPES } from '../core/constants'; +import { processTokens, type UpdateSource } from '../core/token-engine'; +import { getPromptText } from '../core/token-extractor'; +import { isHTMLElement, isTextNode, isTriggerToken } from '../core/type-guards'; +import { findElement, getCurrentSelection, getFirstRange } from '../core/utils'; +import type { PromptInputProps } from '../interfaces'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface UseShortcutsConfig { + isTokenMode: boolean; + tokens?: readonly PromptInputProps.InputToken[]; + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + editableElementRef: React.RefObject; +} + +export interface UseShortcutsResult { + // State + cursorInTrigger: boolean; + setCursorInTrigger: (inTrigger: boolean) => void; + ignoreCursorDetection: React.MutableRefObject; + triggerValueWhenClosed: string; + + // Menu state + activeTriggerToken: PromptInputProps.TriggerToken | null; + activeMenu: PromptInputProps.MenuDefinition | null; + menuIsOpen: boolean; + menuFilterText: string; + + // Trigger wrapper for dropdown positioning + triggerWrapperRef: React.MutableRefObject; + triggerWrapperReady: boolean; + + // Processing + processUserInput: (tokens: PromptInputProps.InputToken[]) => void; + processWithCursor: ( + tokens: PromptInputProps.InputToken[], + options?: { + source?: UpdateSource; + cursorAdjustment?: (savedPos: number, oldTokens: readonly PromptInputProps.InputToken[]) => number; + } + ) => void; + + // Update tracking + markTokensAsSent: (tokens: readonly PromptInputProps.InputToken[]) => void; + setUpdateSource: (source: UpdateSource) => void; +} + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +interface ShortcutsState { + cursorInTrigger: boolean; + setCursorInTrigger: (inTrigger: boolean) => void; + ignoreCursorDetection: React.MutableRefObject; + triggerValueWhenClosed: React.MutableRefObject; + lastSentTokens: React.MutableRefObject; + updateSource: React.MutableRefObject; + isExternalUpdate: (tokens: readonly PromptInputProps.InputToken[] | undefined) => boolean; + markTokensAsSent: (tokens: readonly PromptInputProps.InputToken[]) => void; + setUpdateSource: (source: UpdateSource) => void; +} + +function useShortcutsState(): ShortcutsState { + const [cursorInTrigger, setCursorInTrigger] = useState(false); + const ignoreCursorDetection = useRef(false); + const triggerValueWhenClosed = useRef(''); + const lastSentTokens = useRef(undefined); + const updateSource = useRef('external'); + + const isExternalUpdate = useStableCallback((tokens: readonly PromptInputProps.InputToken[] | undefined): boolean => { + return lastSentTokens.current !== tokens; + }); + + const markTokensAsSent = useStableCallback((tokens: readonly PromptInputProps.InputToken[]) => { + lastSentTokens.current = tokens; + }); + + const setUpdateSource = useStableCallback((source: UpdateSource) => { + updateSource.current = source; + }); + + return { + cursorInTrigger, + setCursorInTrigger, + ignoreCursorDetection, + triggerValueWhenClosed, + lastSentTokens, + updateSource, + isExternalUpdate, + markTokensAsSent, + setUpdateSource, + }; +} + +// ============================================================================ +// TOKEN PROCESSING +// ============================================================================ + +interface ProcessorConfig { + tokens?: readonly PromptInputProps.InputToken[]; + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + editableElementRef: React.RefObject; + state: ShortcutsState; +} + +function useTokenProcessor(config: ProcessorConfig) { + const { tokens, menus, tokensToText, onChange, state } = config; + const previousTokensRef = useRef(tokens); + + const emitTokenChange = useStableCallback((newTokens: PromptInputProps.InputToken[]) => { + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + state.markTokensAsSent(newTokens); + onChange({ value, tokens: newTokens }); + }); + + const processUserInput = useStableCallback((inputTokens: PromptInputProps.InputToken[]) => { + const processed = processTokens( + inputTokens, + { menus, tokensToText }, + { + source: 'user-input', + detectTriggers: true, + } + ); + + // Don't preserve cursor during trigger detection - cursor is already correct in DOM + emitTokenChange(processed); + }); + + const processWithCursor = useStableCallback( + ( + newTokens: PromptInputProps.InputToken[], + options: { + source?: UpdateSource; + cursorAdjustment?: (savedPos: number, oldTokens: readonly PromptInputProps.InputToken[]) => number; + } = {} + ) => { + const source = options.source ?? 'internal'; + state.setUpdateSource(source); + + // Just emit the token change - cursor stays where it is in DOM + emitTokenChange(newTokens); + } + ); + + // Effect: Process external token updates + useEffect(() => { + if (previousTokensRef.current === tokens) { + return; + } + + previousTokensRef.current = tokens; + + if (!state.isExternalUpdate(tokens)) { + return; + } + + state.setUpdateSource('external'); + + if (!tokens || !menus) { + return; + } + + const processed = processTokens( + tokens, + { menus, tokensToText }, + { + source: 'external', + detectTriggers: true, + } + ); + + const hasChanges = processed.length !== tokens.length || processed.some((t, i) => t !== tokens[i]); + + if (hasChanges) { + processWithCursor(processed, { source: 'external' }); + } + }, [tokens, menus, tokensToText, state, processWithCursor]); + + return { + processUserInput, + processWithCursor, + }; +} + +// ============================================================================ +// EFFECTS +// ============================================================================ + +interface EffectsConfig { + isTokenMode: boolean; + tokens?: readonly PromptInputProps.InputToken[]; + editableElementRef: React.RefObject; + state: ShortcutsState; + activeTriggerToken: PromptInputProps.TriggerToken | null; +} + +function isElementInView(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + + // Find scrollable parent + const scrollableParent = getFirstScrollableParent(element); + + if (scrollableParent) { + // Check against scrollable parent + const parentRect = scrollableParent.getBoundingClientRect(); + + // Calculate visible portion + const visibleTop = Math.max(rect.top, parentRect.top); + const visibleBottom = Math.min(rect.bottom, parentRect.bottom); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const totalHeight = rect.height; + + // Consider visible if at least 50% is showing + return visibleHeight / totalHeight >= 0.5; + } + + // Check against viewport + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const visibleTop = Math.max(rect.top, 0); + const visibleBottom = Math.min(rect.bottom, viewportHeight); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const totalHeight = rect.height; + + // Consider visible if at least 50% is showing + return visibleHeight / totalHeight >= 0.5; +} + +function isCursorInTriggerElement(): boolean { + const selection = getCurrentSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = getFirstRange(); + if (!range?.collapsed) { + return false; + } + + let startElement: HTMLElement | null = null; + if (isHTMLElement(range.startContainer)) { + startElement = range.startContainer; + } else if (range.startContainer.parentElement) { + startElement = range.startContainer.parentElement; + } + + if (!startElement) { + return false; + } + + const triggerElement = findUpUntil(startElement, node => node.getAttribute('data-type') === ELEMENT_TYPES.TRIGGER); + + if (!triggerElement) { + return false; + } + + if (isTextNode(range.startContainer) && range.startContainer.parentElement === triggerElement) { + // Cursor must be after the trigger character (first character) + const result = range.startOffset > 0; + return result; + } + + return true; +} + +function useShortcutsEffects(config: EffectsConfig) { + const { activeTriggerToken, editableElementRef, state, tokens } = config; + + // Effect: Track trigger value when menu closes + useEffect(() => { + if (!state.cursorInTrigger && activeTriggerToken) { + state.triggerValueWhenClosed.current = activeTriggerToken.value; + } else if (state.cursorInTrigger) { + state.triggerValueWhenClosed.current = ''; + } + }, [state.cursorInTrigger, activeTriggerToken, state.triggerValueWhenClosed]); + + // Effect: Menu state management (cursor position + visibility) + useEffect(() => { + const hasTriggers = tokens?.some(isTriggerToken); + + if (!hasTriggers || !editableElementRef.current) { + state.setCursorInTrigger(false); + return; + } + + // Unified check for menu state: cursor in trigger AND trigger visible + const checkMenuState = () => { + if (!editableElementRef.current || state.ignoreCursorDetection.current) { + return; + } + + const isInTrigger = isCursorInTriggerElement(); + + // When cursor is in a trigger, check if THAT trigger is visible (not necessarily activeTriggerToken) + let triggerIsVisible = false; + if (isInTrigger) { + const selection = getCurrentSelection(); + if (selection?.rangeCount) { + const range = getFirstRange(); + if (range) { + let startElement: HTMLElement | null = null; + if (isHTMLElement(range.startContainer)) { + startElement = range.startContainer; + } else if (range.startContainer.parentElement) { + startElement = range.startContainer.parentElement; + } + + if (startElement) { + const triggerElement = findUpUntil( + startElement, + node => node.getAttribute('data-type') === ELEMENT_TYPES.TRIGGER + ); + if (triggerElement) { + triggerIsVisible = isElementInView(triggerElement); + } + } + } + } + } + + // Menu should be open if cursor is in trigger AND that trigger is visible + const shouldBeOpen = isInTrigger && triggerIsVisible; + + if (shouldBeOpen !== state.cursorInTrigger) { + state.setCursorInTrigger(shouldBeOpen); + } + }; + + // Initial check + checkMenuState(); + + // Listen to cursor changes + document.addEventListener('selectionchange', checkMenuState); + + // Listen to scroll changes + const scrollableParent = getFirstScrollableParent(editableElementRef.current); + if (scrollableParent) { + scrollableParent.addEventListener('scroll', checkMenuState); + } + + return () => { + document.removeEventListener('selectionchange', checkMenuState); + if (scrollableParent) { + scrollableParent.removeEventListener('scroll', checkMenuState); + } + }; + }, [tokens, state, editableElementRef, activeTriggerToken]); +} + +// MAIN HOOK + +export function useShortcuts(config: UseShortcutsConfig): UseShortcutsResult { + const { isTokenMode, tokens, menus, tokensToText, onChange, editableElementRef } = config; + + // Initialize state + const state = useShortcutsState(); + + // Derive active trigger token - find the one where cursor is located + // This needs to update whenever cursor moves, not just when cursorInTrigger changes + const [cursorUpdateTrigger, setCursorUpdateTrigger] = useState(0); + + const activeTriggerToken = useMemo((): PromptInputProps.TriggerToken | null => { + if (!tokens) { + return null; + } + + // Always return first trigger for cursor detection effect to work + const firstTrigger = tokens.find(isTriggerToken) ?? null; + + if (!firstTrigger) { + return null; + } + + // If cursor is in trigger and we have DOM access, find the specific trigger at cursor + if (state.cursorInTrigger && editableElementRef.current) { + const selection = getCurrentSelection(); + if (selection?.rangeCount) { + const range = getFirstRange(); + if (range?.collapsed) { + let startElement: HTMLElement | null = null; + if (isHTMLElement(range.startContainer)) { + startElement = range.startContainer; + } else if (range.startContainer.parentElement) { + startElement = range.startContainer.parentElement; + } + + if (startElement) { + const triggerElement = findUpUntil( + startElement, + node => node.getAttribute('data-type') === ELEMENT_TYPES.TRIGGER + ); + + if (triggerElement) { + const instanceId = triggerElement.getAttribute('data-id'); + if (instanceId) { + // Find trigger with matching instanceId + const matchingTrigger = tokens.find(t => isTriggerToken(t) && t.id === instanceId) as + | PromptInputProps.TriggerToken + | undefined; + if (matchingTrigger) { + return matchingTrigger; + } + } + } + } + } + } + } + + // Fallback to first trigger + return firstTrigger; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tokens, state.cursorInTrigger, editableElementRef, cursorUpdateTrigger]); + + // Listen to cursor changes to update activeTriggerToken + useEffect(() => { + const handleSelectionChange = () => { + if (state.cursorInTrigger) { + setCursorUpdateTrigger(prev => prev + 1); + } + }; + + document.addEventListener('selectionchange', handleSelectionChange); + return () => document.removeEventListener('selectionchange', handleSelectionChange); + }, [state.cursorInTrigger]); + + // Also trigger update when cursorInTrigger changes to true + useEffect(() => { + if (state.cursorInTrigger) { + setCursorUpdateTrigger(prev => prev + 1); + } + }, [state.cursorInTrigger]); + + const activeMenu = useMemo( + () => + activeTriggerToken && state.cursorInTrigger + ? (menus?.find(m => m.trigger === activeTriggerToken.triggerChar) ?? null) + : null, + [activeTriggerToken, state.cursorInTrigger, menus] + ); + + const menuIsOpen = !!activeMenu; + const menuFilterText = activeTriggerToken?.value ?? ''; + + // Initialize processor + const processor = useTokenProcessor({ + tokens, + menus, + tokensToText, + onChange, + editableElementRef, + state, + }); + + // Setup effects + useShortcutsEffects({ + isTokenMode, + tokens, + editableElementRef, + state, + activeTriggerToken, + }); + + // Manage trigger wrapper ref for dropdown positioning + const triggerWrapperRef = useRef(null); + const [triggerWrapperReady, setTriggerWrapperReady] = useState(false); + + useEffect(() => { + // Only update ref when menu is actually open (cursor is in a trigger) + if (activeTriggerToken && menuIsOpen && editableElementRef.current) { + const triggerElement = findElement(editableElementRef.current, { + tokenType: ELEMENT_TYPES.TRIGGER, + tokenId: activeTriggerToken.id, + }); + + if (triggerElement) { + triggerWrapperRef.current = triggerElement; + setTriggerWrapperReady(true); + } + } else if (!menuIsOpen) { + triggerWrapperRef.current = null; + setTriggerWrapperReady(false); + } + }, [activeTriggerToken, menuIsOpen, editableElementRef]); + + return { + cursorInTrigger: state.cursorInTrigger, + setCursorInTrigger: state.setCursorInTrigger, + ignoreCursorDetection: state.ignoreCursorDetection, + triggerValueWhenClosed: state.triggerValueWhenClosed.current, + activeTriggerToken, + activeMenu, + menuIsOpen, + menuFilterText, + triggerWrapperRef, + triggerWrapperReady, + processUserInput: processor.processUserInput, + processWithCursor: processor.processWithCursor, + markTokensAsSent: state.markTokensAsSent, + setUpdateSource: state.setUpdateSource, + }; +} diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss index d993ee6758..cca7c09fe1 100644 --- a/src/prompt-input/styles.scss +++ b/src/prompt-input/styles.scss @@ -122,32 +122,50 @@ $invalid-border-offset: constants.$invalid-control-left-padding; ); background-color: var(#{custom-props.$promptInputStyleBackgroundFocus}, awsui.$color-background-input-default); } + + // Prevent focus styles when disabled + &.disabled:focus-within, + &.disabled:focus { + @include styles.form-disabled-element( + $background-color: var( + #{custom-props.$promptInputStyleBackgroundDisabled}, + awsui.$color-background-input-disabled + ), + $border-color: var(#{custom-props.$promptInputStyleBorderColorDisabled}, awsui.$color-border-input-disabled), + $color: var(#{custom-props.$promptInputStyleColorDisabled}, awsui.$color-text-input-disabled), + $cursor: default + ); + box-shadow: var(#{custom-props.$promptInputStyleBoxShadowDisabled}); + } } .textarea { @include styles.styles-reset; - @include styles.control-border-radius-full(); @include styles.font-body-m; // Restore browsers' default resize values resize: none; // Restore default text cursor cursor: text; - // Allow multi-line placeholders + // Allow multi-line placeholders and word wrapping white-space: pre-wrap; - background-color: inherit; + word-wrap: break-word; + overflow-wrap: break-word; + background-color: transparent; padding-block: styles.$control-padding-vertical; padding-inline: styles.$control-padding-horizontal; color: var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-body-default); - max-inline-size: 100%; inline-size: 100%; display: block; box-sizing: border-box; + overflow-y: auto; + overflow-x: hidden; border: 0; - &::placeholder { + &.placeholder-visible::before { + content: attr(data-placeholder); @include styles.form-placeholder( $color: var(#{custom-props.$promptInputStylePlaceholderColor}, awsui.$color-text-input-placeholder), $font-size: var(#{custom-props.$promptInputStylePlaceholderFontSize}), @@ -155,6 +173,10 @@ $invalid-border-offset: constants.$invalid-control-left-padding; $font-weight: var(#{custom-props.$promptInputStylePlaceholderFontWeight}) ); opacity: 1; + pointer-events: none; + position: absolute; + inset-block-start: styles.$control-padding-vertical; + inset-inline-start: styles.$control-padding-horizontal; } &:hover { @@ -182,9 +204,19 @@ $invalid-border-offset: constants.$invalid-control-left-padding; padding-inline-start: $invalid-border-offset; } - &:disabled { + &:disabled, + &.textarea-disabled { color: var(#{custom-props.$promptInputStyleColorDisabled}, awsui.$color-text-input-disabled); + @include styles.form-disabled-element; cursor: default; + overflow-y: hidden; + + // Prevent focus styles when disabled + &:focus-within, + &:focus { + @include styles.form-disabled-element; + box-shadow: none; + } &::placeholder { @include styles.form-placeholder-disabled; @@ -198,12 +230,25 @@ $invalid-border-offset: constants.$invalid-control-left-padding; var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-body-default) ); } + // Placeholder for disabled contentEditable div + &.placeholder-visible::before { + @include styles.form-placeholder-disabled; + opacity: 1; + pointer-events: none; // Prevent cursor from getting stuck on placeholder in Safari + } &-wrapper { display: flex; + position: relative; } } +.editable-wrapper { + flex: 1; + min-inline-size: 0; + position: relative; +} + .primary-action { align-self: flex-end; flex-shrink: 0; @@ -271,3 +316,32 @@ $invalid-border-offset: constants.$invalid-control-left-padding; align-self: stretch; cursor: text; } + +.token-container { + display: inline-block; + user-select: all; + -webkit-user-select: all; + -moz-user-select: all; + padding-inline: awsui.$space-xxxs; +} + +// Paragraph elements - reset browser default margins/padding +.paragraph { + @include styles.styles-reset; + // Ensure paragraphs are only as tall as their line-height + margin: 0; + padding: 0; + // Preserve whitespace including trailing spaces + white-space: pre-wrap; + // Inherit color from parent (textarea) for disabled/readonly states + color: inherit; + + // Prevent focus styles on paragraphs when parent textarea is disabled + .textarea-disabled & { + &:focus-within, + &:focus { + background: inherit; + outline: none; + } + } +} diff --git a/src/prompt-input/test-classes/styles.scss b/src/prompt-input/test-classes/styles.scss index a395897823..f783c7ddc2 100644 --- a/src/prompt-input/test-classes/styles.scss +++ b/src/prompt-input/test-classes/styles.scss @@ -10,6 +10,10 @@ /* used in test-utils */ } +.content-editable { + /* used in test-utils - contentEditable element for token mode */ +} + .action-button { /* used in test-utils */ } diff --git a/src/prompt-input/tokens/use-editable-tokens.ts b/src/prompt-input/tokens/use-editable-tokens.ts new file mode 100644 index 0000000000..c22ecd140b --- /dev/null +++ b/src/prompt-input/tokens/use-editable-tokens.ts @@ -0,0 +1,377 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; + +import { ELEMENT_TYPES, SPECIAL_CHARS } from '../core/constants'; +import { getCursorPosition, getTokenCursorLength, setCursorPosition } from '../core/cursor-manager'; +import { type EditableState } from '../core/event-handlers'; +import { extractTokensFromDOM, getPromptText, moveForbiddenTextAfterPinnedTokens } from '../core/token-extractor'; +import { renderTokensToDOM } from '../core/token-renderer'; +import { + isBreakToken, + isBRElement, + isReferenceToken, + isTextNode, + isTextToken, + isTriggerToken, +} from '../core/type-guards'; +import { createParagraph, ensureEmptyState, findAllParagraphs, findElements, insertAfter } from '../core/utils'; +import { PromptInputProps } from '../interfaces'; + +function shouldRerender( + oldTokens: readonly PromptInputProps.InputToken[] | undefined, + newTokens: readonly PromptInputProps.InputToken[] | undefined +): boolean { + if (!oldTokens || !newTokens) { + return true; + } + + if (oldTokens.length !== newTokens.length) { + return true; + } + + for (let i = 0; i < oldTokens.length; i++) { + const oldToken = oldTokens[i]; + const newToken = newTokens[i]; + + if (oldToken.type !== newToken.type) { + return true; + } + + if (isReferenceToken(oldToken) && isReferenceToken(newToken)) { + if (oldToken.id !== newToken.id) { + return true; + } + } + } + + return false; +} + +interface UseEditableOptions { + elementRef: React.RefObject; + reactContainersRef: React.MutableRefObject>; + tokens?: readonly PromptInputProps.InputToken[]; + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + adjustInputHeight: () => void; + disabled?: boolean; + readOnly?: boolean; + editableState: EditableState; + ignoreCursorDetection: React.MutableRefObject; + lastKnownCursorPositionRef: React.MutableRefObject; +} + +interface UseEditableReturn { + handleInput: () => void; + editableState: EditableState; +} + +export function useEditableTokens({ + elementRef, + reactContainersRef, + tokens, + menus, + tokensToText, + onChange, + adjustInputHeight, + disabled = false, + readOnly = false, + editableState, + ignoreCursorDetection, + lastKnownCursorPositionRef, +}: UseEditableOptions): UseEditableReturn { + const lastRenderedTokensRef = useRef(undefined); + const lastEmittedTokensRef = useRef(undefined); + const lastDisabledRef = useRef(disabled); + const lastReadOnlyRef = useRef(readOnly); + const skipNextZwnjUpdateRef = useRef(false); + const skipCursorRestoreRef = useRef(false); + + const handleInput = useCallback(() => { + if (!elementRef.current) { + return; + } + + // Capture cursor position BEFORE any DOM manipulation + const cursorPos = getCursorPosition(elementRef.current); + lastKnownCursorPositionRef.current = cursorPos; + + // Read flags from shared state + if (editableState.skipNextZwnjUpdate) { + skipNextZwnjUpdateRef.current = true; + editableState.skipNextZwnjUpdate = false; + } + + if (editableState.skipCursorRestore) { + skipCursorRestoreRef.current = true; + editableState.skipCursorRestore = false; + } + + if (elementRef.current.children.length === 0) { + ensureEmptyState(elementRef.current); + } + + const paragraphs = findAllParagraphs(elementRef.current); + + paragraphs.forEach(p => { + const cursorSpots = findElements(p, { + tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER], + }); + cursorSpots.forEach(spot => { + const content = spot.textContent || ''; + const cleanContent = content.replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + + if (cleanContent) { + const textNode = document.createTextNode(cleanContent); + const wrapper = spot.parentElement; + if (wrapper) { + if (spot.getAttribute('data-type') === ELEMENT_TYPES.CURSOR_SPOT_BEFORE) { + wrapper.parentNode?.insertBefore(textNode, wrapper); + } else { + insertAfter(textNode, wrapper); + } + } + } + spot.textContent = SPECIAL_CHARS.ZWNJ; + }); + }); + + const directTextNodes = Array.from(elementRef.current.childNodes).filter( + node => isTextNode(node) && node.textContent?.trim() + ); + + if (directTextNodes.length > 0) { + // Find or create a paragraph to move the text into + let targetP = findAllParagraphs(elementRef.current)[0]; + if (!targetP) { + targetP = createParagraph(); + elementRef.current.appendChild(targetP); + } + + // Move text nodes into the paragraph + directTextNodes.forEach(textNode => { + targetP!.appendChild(textNode); + }); + } + + // Extract tokens + let extractedTokens = extractTokensFromDOM(elementRef.current, menus); + + // If all content was deleted or only breaks remain, ensure proper empty state + const onlyBreaks = extractedTokens.every(isBreakToken); + + if (extractedTokens.length === 0 || onlyBreaks) { + // Ensure we have exactly one paragraph with BR + const paragraphs = findAllParagraphs(elementRef.current); + const hasValidEmptyState = + paragraphs.length === 1 && isBRElement(paragraphs[0].firstChild, ELEMENT_TYPES.TRAILING_BREAK); + if (!hasValidEmptyState) { + ensureEmptyState(elementRef.current); + // Cursor will be restored by unified restoration to position 0 + lastKnownCursorPositionRef.current = 0; + } + extractedTokens = []; + } + + const movedTokens = moveForbiddenTextAfterPinnedTokens(extractedTokens); + const tokensWereMoved = movedTokens.some((t, i) => t !== extractedTokens[i]); + + if (tokensWereMoved) { + extractedTokens = movedTokens; + + // When tokens are moved, position cursor after all content + const position = movedTokens.reduce((sum, token) => sum + getTokenCursorLength(token), 0); + lastKnownCursorPositionRef.current = position; + + // Render immediately to avoid showing intermediate state + renderTokensToDOM(movedTokens, elementRef.current, reactContainersRef.current, { disabled, readOnly }); + + // Position cursor immediately to avoid flicker + requestAnimationFrame(() => { + if (elementRef.current) { + setCursorPosition(elementRef.current, position); + } + }); + } + + const value = tokensToText ? tokensToText(extractedTokens) : getPromptText(extractedTokens); + onChange({ value, tokens: extractedTokens }); + + lastEmittedTokensRef.current = extractedTokens; + + adjustInputHeight(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onChange, adjustInputHeight, tokensToText]); + + useLayoutEffect(() => { + if (!elementRef.current || disabled) { + return; + } + if (elementRef.current.children.length === 0) { + renderTokensToDOM(tokens ?? [], elementRef.current, reactContainersRef.current, { disabled, readOnly }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!elementRef.current) { + return; + } + + // Check if disabled/readOnly changed - force rerender if so + const stateChanged = lastDisabledRef.current !== disabled || lastReadOnlyRef.current !== readOnly; + lastDisabledRef.current = disabled; + lastReadOnlyRef.current = readOnly; + + // Check if a trigger split+merge happened (same token count, but text token value changed) + // This is a structural change that needs cursor repositioning + const triggerSplitAndMerged = + lastRenderedTokensRef.current && + tokens && + lastRenderedTokensRef.current.length === tokens.length && + tokens.some((token, i) => { + const oldToken = lastRenderedTokensRef.current![i]; + const prevToken = i > 0 ? tokens[i - 1] : null; + // Detect: text token after trigger, value changed by exactly 1 space at start + return ( + isTextToken(token) && + isTextToken(oldToken) && + prevToken && + isTriggerToken(prevToken) && + token.value.length === oldToken.value.length + 1 && + token.value.startsWith(' ') && + token.value.substring(1) === oldToken.value + ); + }); + + const needsRerender = + stateChanged || shouldRerender(lastRenderedTokensRef.current, tokens) || triggerSplitAndMerged; + + if (!needsRerender) { + lastRenderedTokensRef.current = tokens; + return; + } + + if (lastRenderedTokensRef.current && tokens && lastRenderedTokensRef.current.length === 0 && tokens.length === 0) { + lastRenderedTokensRef.current = tokens; + return; + } + + if (skipNextZwnjUpdateRef.current) { + skipNextZwnjUpdateRef.current = false; + } + + if (editableState.skipCursorRestore) { + skipCursorRestoreRef.current = true; + editableState.skipCursorRestore = false; + } + + const shouldRestoreCursor = !skipCursorRestoreRef.current; + + skipCursorRestoreRef.current = false; + + let savedCursorPosition = 0; + if (shouldRestoreCursor) { + // Check if we have a deletion context with a pre-calculated position + if (editableState.deletionContext) { + savedCursorPosition = editableState.deletionContext.cursorPosition; + editableState.deletionContext = null; + } else { + savedCursorPosition = lastKnownCursorPositionRef.current; + } + } + + lastRenderedTokensRef.current = tokens; + + // Calculate cursor position for space-after-trigger case + let cursorPositionToRestore: number | null = null; + if (triggerSplitAndMerged && tokens) { + // Special case: space was added after trigger, position after the space + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const nextToken = tokens[i + 1]; + + if (isTriggerToken(token) && nextToken && isTextToken(nextToken) && nextToken.value.startsWith(' ')) { + cursorPositionToRestore = tokens.slice(0, i + 1).reduce((sum, t) => sum + getTokenCursorLength(t), 0) + 1; + break; + } + } + } + + renderTokensToDOM(tokens ?? [], elementRef.current, reactContainersRef.current, { disabled, readOnly }); + + // ============================================================================ + // UNIFIED CURSOR RESTORATION + // ============================================================================ + // After renderTokensToDOM, always restore cursor position using lastKnownCursorPositionRef + // Special cases update the ref before restoration, not position directly + + requestAnimationFrame(() => + requestAnimationFrame(() => { + if (!elementRef.current) { + return; + } + + // Calculate target position based on special cases + let targetPosition = savedCursorPosition; + + // Special case 1: Menu selection - position after the selected reference + if (editableState.menuSelectionTokenId) { + const tokenId = editableState.menuSelectionTokenId; + const isPinned = editableState.menuSelectionIsPinned; + editableState.menuSelectionTokenId = null; + editableState.menuSelectionIsPinned = false; + + let targetWrapper: Element | null = null; + + if (isPinned) { + const pinnedElements = findElements(elementRef.current, { tokenType: ELEMENT_TYPES.PINNED }); + const lastPinned = pinnedElements[pinnedElements.length - 1]; + if (lastPinned) { + targetWrapper = lastPinned.closest(`[data-type="${ELEMENT_TYPES.PINNED}"]`); + } + } else { + const wrappers = findElements(elementRef.current, { + tokenType: ELEMENT_TYPES.REFERENCE, + tokenId, + }); + targetWrapper = wrappers[wrappers.length - 1]; + } + + if (targetWrapper && tokens) { + const refIndex = tokens.findIndex(t => isReferenceToken(t) && t.id === tokenId); + if (refIndex >= 0) { + // Calculate position after this reference + targetPosition = tokens + .slice(0, refIndex + 1) + .reduce((sum, token) => sum + getTokenCursorLength(token), 0); + } + } + + ignoreCursorDetection.current = false; + } + + // Special case 2: Space after trigger - position after the space + if (cursorPositionToRestore !== null) { + targetPosition = cursorPositionToRestore; + } + + // Unified restoration: set cursor to target position + setCursorPosition(elementRef.current, targetPosition); + }) + ); + + adjustInputHeight(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [disabled, readOnly, tokens, adjustInputHeight]); + + return { + handleInput, + editableState, + }; +} + +export type SetCursorPositionCallback = (position: number | null) => void; diff --git a/src/prompt-input/utils/insert-text-content-editable.ts b/src/prompt-input/utils/insert-text-content-editable.ts new file mode 100644 index 0000000000..cec9d7eaea --- /dev/null +++ b/src/prompt-input/utils/insert-text-content-editable.ts @@ -0,0 +1,141 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isPinnedReferenceToken, isTextToken, isTriggerToken } from '../core/type-guards'; +import { PromptInputProps } from '../interfaces'; + +function textToTokens(text: string, menus: readonly PromptInputProps.MenuDefinition[]): PromptInputProps.InputToken[] { + return text.split('\n').flatMap((line, i) => { + const tokens: PromptInputProps.InputToken[] = []; + if (i > 0) { + tokens.push({ type: 'break', value: '\n' }); + } + if (!line) { + return tokens; + } + + const firstChar = line.charAt(0); + const matchingMenu = menus.find(m => m.trigger === firstChar); + + tokens.push( + matchingMenu + ? { type: 'trigger', triggerChar: firstChar, value: line.substring(1), id: undefined } + : { type: 'text', value: line } + ); + return tokens; + }); +} + +function getTokenLength(token: PromptInputProps.InputToken): number { + if (isTextToken(token)) { + return token.value.length; + } + if (isTriggerToken(token)) { + return 1 + token.value.length; + } + return 1; // Reference/pinned are atomic +} + +function insertTextIntoTokens( + tokens: readonly PromptInputProps.InputToken[], + text: string, + position: number, + menus: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const textTokens = textToTokens(text, menus); + const result: PromptInputProps.InputToken[] = []; + let currentPosition = 0; + let inserted = false; + + for (const token of tokens) { + const tokenLength = getTokenLength(token); + + if (!inserted && position >= currentPosition && position < currentPosition + tokenLength) { + if (isTextToken(token)) { + const offset = position - currentPosition; + if (offset > 0) { + result.push({ type: 'text', value: token.value.substring(0, offset) }); + } + result.push(...textTokens); + if (offset < token.value.length) { + result.push({ type: 'text', value: token.value.substring(offset) }); + } + } else if (isTriggerToken(token)) { + const offset = position - currentPosition; + if (offset === 0) { + result.push(...textTokens, token); + } else { + const valueOffset = offset - 1; + result.push({ + ...token, + value: token.value.substring(0, valueOffset) + text + token.value.substring(valueOffset), + }); + } + } + inserted = true; + } else if (!inserted && position === currentPosition) { + result.push(...textTokens, token); + inserted = true; + } else { + result.push(token); + } + + currentPosition += tokenLength; + } + + if (!inserted) { + result.push(...textTokens); + } + + // Merge adjacent text tokens + return result.reduce((merged, token) => { + const last = merged[merged.length - 1]; + if (isTextToken(token) && last && isTextToken(last)) { + last.value += token.value; + } else { + merged.push(token); + } + return merged; + }, []); +} + +export function insertTextIntoContentEditable( + element: HTMLElement, + text: string, + cursorStart: number | undefined, + cursorEnd: number | undefined, + tokens: readonly PromptInputProps.InputToken[], + menus: readonly PromptInputProps.MenuDefinition[], + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, + tokensToText: (tokens: readonly PromptInputProps.InputToken[]) => string, + lastKnownCursorPosition: number, + lastKnownCursorPositionRef: React.MutableRefObject +): void { + element.focus(); + + // Calculate pinned token offset + const positionAfterPinned = tokens.filter(isPinnedReferenceToken).length; + + // Determine insertion position + const insertPosition = + cursorStart !== undefined + ? cursorStart === 0 + ? positionAfterPinned + : cursorStart + : Math.max(lastKnownCursorPosition, positionAfterPinned); + + // Insert text and calculate final cursor position + const textTokens = textToTokens(text, menus); + const insertedLength = textTokens.reduce((sum, token) => sum + getTokenLength(token), 0); + const newTokens = insertTextIntoTokens(tokens, text, insertPosition, menus); + const finalPosition = + cursorEnd !== undefined ? (cursorEnd === 0 ? positionAfterPinned : cursorEnd) : insertPosition + insertedLength; + + // Update cursor position ref for unified restoration + if (lastKnownCursorPositionRef) { + lastKnownCursorPositionRef.current = finalPosition; + } + + // Trigger state update and re-render + onChange({ value: tokensToText(newTokens), tokens: newTokens }); +} diff --git a/src/test-utils/dom/prompt-input/index.ts b/src/test-utils/dom/prompt-input/index.ts index a241b1f79a..85c76ec966 100644 --- a/src/test-utils/dom/prompt-input/index.ts +++ b/src/test-utils/dom/prompt-input/index.ts @@ -1,18 +1,68 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ComponentWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; +import { ComponentWrapper, createWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; +import { escapeSelector } from '@cloudscape-design/test-utils-core/utils'; import { act, setNativeValue } from '@cloudscape-design/test-utils-core/utils-dom'; +import OptionWrapper from '../internal/option'; + +import dropdownStyles from '../../../internal/components/dropdown/styles.selectors.js'; +import selectableStyles from '../../../internal/components/selectable-item/styles.selectors.js'; import testutilStyles from '../../../prompt-input/test-classes/styles.selectors.js'; +export class PromptInputMenuWrapper extends ComponentWrapper { + findOptions(): Array { + return this.findAll(`.${selectableStyles['selectable-item']}[data-test-index]`).map( + (elementWrapper: ElementWrapper) => new OptionWrapper(elementWrapper.getElement()) + ); + } + + /** + * Returns an option from the menu. + * + * @param optionIndex 1-based index of the option to select. + */ + findOption(optionIndex: number): OptionWrapper | null { + return this.findComponent( + `.${selectableStyles['selectable-item']}[data-test-index="${optionIndex}"]`, + OptionWrapper + ); + } + + /** + * Returns an option from the menu by its value + * + * @param value The 'value' of the option. + */ + findOptionByValue(value: string): OptionWrapper | null { + const toReplace = escapeSelector(value); + return this.findComponent(`.${OptionWrapper.rootSelector}[data-value="${toReplace}"]`, OptionWrapper); + } +} + export default class PromptInputWrapper extends ComponentWrapper { static rootSelector = testutilStyles.root; + /** + * Finds the native textarea element. + * + * Note: When menus are defined, the component uses a contentEditable element instead of a textarea. + * In this case, this method may fail to find the textarea element. Use findContentEditableElement() + * or the getValue()/setValue() methods instead. + */ findNativeTextarea(): ElementWrapper { return this.findByClassName(testutilStyles.textarea)!; } + /** + * Finds the contentEditable element used when menus are defined. + * Returns null if the component does not have menus defined. + */ + findContentEditableElement(): ElementWrapper | null { + return this.find('[contenteditable="true"]'); + } + /** * Finds the action button. Note that, despite its typings, this may return null. */ @@ -36,25 +86,122 @@ export default class PromptInputWrapper extends ComponentWrapper { } /** + * Finds the menu dropdown (always in portal due to expandToViewport=true). + */ + findMenu(): PromptInputMenuWrapper | null { + return createWrapper().findComponent(`.${dropdownStyles.dropdown}[data-open=true]`, PromptInputMenuWrapper); + } + + /** + * Gets the value of the component. + * + * Returns the current value of the textarea (when no menus are defined) or the text content of the contentEditable element (when menus are defined). + */ + @usesDom getValue(): string { + const contentEditable = this.findContentEditableElement(); + if (contentEditable) { + return contentEditable.getElement().textContent || ''; + } + const textarea = this.findNativeTextarea(); + return textarea ? textarea.getElement().value : ''; + } + + /** + * Sets the value of the component by directly setting text content. + * This does NOT trigger menu detection. Use the component ref's insertText() method + * to simulate typing and trigger menus. + * + * @param value String value to set the component to. + */ + @usesDom setValue(value: string): void { + const contentEditable = this.findContentEditableElement(); + if (contentEditable) { + const element = contentEditable.getElement(); + act(() => { + element.textContent = value; + element.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true })); + }); + } else { + this.setTextareaValue(value); + } + } + + /** + * @deprecated Use getValue() instead. + * * Gets the value of the component. * * Returns the current value of the textarea. */ @usesDom getTextareaValue(): string { - return this.findNativeTextarea().getElement().value; + return this.getValue(); } /** + * @deprecated Use setValue() instead. + * * Sets the value of the component and calls the onChange handler. * * @param value value to set the textarea to. */ @usesDom setTextareaValue(value: string): void { - const element = this.findNativeTextarea().getElement(); + const textarea = this.findNativeTextarea(); + if (textarea) { + const element = textarea.getElement(); + act(() => { + const event = new Event('change', { bubbles: true, cancelable: false }); + setNativeValue(element, value); + element.dispatchEvent(event); + }); + } + } + + /** + * Checks if the menu is currently open. + */ + @usesDom + isMenuOpen(): boolean { + const menu = this.findMenu(); + return menu !== null; + } + + /** + * Selects an option from the menu by simulating mouse events. + * + * @param value value of option to select + */ + @usesDom + selectMenuOptionByValue(value: string): void { + act(() => { + const menu = this.findMenu(); + if (!menu) { + throw new Error('Menu not found'); + } + const option = menu.findOptionByValue(value); + if (!option) { + throw new Error(`Option with value "${value}" not found in menu`); + } + option.fireEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + } + + /** + * Selects an option from the menu by simulating mouse events. + * + * @param optionIndex 1-based index of the option to select + */ + @usesDom + selectMenuOption(optionIndex: number): void { act(() => { - const event = new Event('change', { bubbles: true, cancelable: false }); - setNativeValue(element, value); - element.dispatchEvent(event); + const menu = this.findMenu(); + if (!menu) { + throw new Error('Menu not found'); + } + const option = menu.findOption(optionIndex); + if (!option) { + throw new Error(`Option at index ${optionIndex} not found in menu`); + } + option.fireEvent(new MouseEvent('mouseup', { bubbles: true })); }); } } diff --git a/src/token/internal.tsx b/src/token/internal.tsx index 9c54d87111..3bd265f8d6 100644 --- a/src/token/internal.tsx +++ b/src/token/internal.tsx @@ -97,8 +97,10 @@ function InternalToken({ } }; + const SpanOrDivTag = isInline ? 'span' : 'div'; + return ( -
-
)} -
+ {!!tooltipContent && isInline && isEllipsisActive && showTooltip && ( )} -
+ ); } From 0cf5588743d45e806f380a1a51ef0102751494b8 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 26 Feb 2026 16:34:16 +0100 Subject: [PATCH 2/9] Fix space insertion after closing a trigger menu --- pages/prompt-input/shortcuts.page.tsx | 2 +- src/prompt-input/core/cursor-manager.ts | 7 +++++-- src/prompt-input/core/event-handlers.ts | 19 ++++++++++++++----- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/pages/prompt-input/shortcuts.page.tsx b/pages/prompt-input/shortcuts.page.tsx index 3f98b2cb65..720da2588f 100644 --- a/pages/prompt-input/shortcuts.page.tsx +++ b/pages/prompt-input/shortcuts.page.tsx @@ -253,7 +253,7 @@ export default function PromptInputShortcutsPage() { }, [plainTextValue]); useEffect(() => { - if (items.length === 0) { + if (items.length === 0 && enableAutoFocus) { ref.current?.focus(); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/prompt-input/core/cursor-manager.ts b/src/prompt-input/core/cursor-manager.ts index 51ac7daf8e..c50e5f4c93 100644 --- a/src/prompt-input/core/cursor-manager.ts +++ b/src/prompt-input/core/cursor-manager.ts @@ -3,7 +3,7 @@ import type { PromptInputProps } from '../interfaces'; import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; -import { isBreakToken, isHTMLElement, isTextNode, isTextToken } from './type-guards'; +import { isBreakToken, isHTMLElement, isTextNode, isTextToken, isTriggerToken } from './type-guards'; import { findAllParagraphs, getTokenType } from './utils'; // HELPER FUNCTIONS @@ -309,7 +309,10 @@ export function getTokenCursorLength(token: PromptInputProps.InputToken): number if (isBreakToken(token)) { return 0; } - return 1; + if (isTriggerToken(token)) { + return 1 + token.value.length; // trigger char + value + } + return 1; // references } export function getCursorPositionAtIndex(tokens: readonly PromptInputProps.InputToken[], index: number): number { diff --git a/src/prompt-input/core/event-handlers.ts b/src/prompt-input/core/event-handlers.ts index fb5fbb3f46..eabf9c9791 100644 --- a/src/prompt-input/core/event-handlers.ts +++ b/src/prompt-input/core/event-handlers.ts @@ -877,18 +877,27 @@ export function handleSpaceAfterClosedTrigger( const spaceNode = document.createTextNode(' '); insertAfter(spaceNode, triggerElement); - // Calculate cursor position after the space for unified restoration + // Calculate cursor position: after trigger + after space const tokens = extractTokensFromDOM(editableElement); let cursorPosition = 0; let foundTrigger = false; - for (const token of tokens) { + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.type === 'trigger' && !foundTrigger) { - cursorPosition += getTokenCursorLength(token) + 1; // trigger + space foundTrigger = true; - break; + cursorPosition += getTokenCursorLength(token); + + // Check if next token is the space we just inserted + const nextToken = tokens[i + 1]; + if (nextToken && nextToken.type === 'text' && nextToken.value.startsWith(' ')) { + cursorPosition += 1; // Position after the space + break; + } + } else { + cursorPosition += getTokenCursorLength(token); } - cursorPosition += getTokenCursorLength(token); } // Store position for unified restoration From a72028e6b7aa8621ad03ac92bbb280fa9962c0e6 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 26 Feb 2026 18:30:37 +0100 Subject: [PATCH 3/9] Fix autofocus issues --- src/prompt-input/tokens/use-editable-tokens.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/prompt-input/tokens/use-editable-tokens.ts b/src/prompt-input/tokens/use-editable-tokens.ts index c22ecd140b..f40b1dd079 100644 --- a/src/prompt-input/tokens/use-editable-tokens.ts +++ b/src/prompt-input/tokens/use-editable-tokens.ts @@ -190,8 +190,9 @@ export function useEditableTokens({ renderTokensToDOM(movedTokens, elementRef.current, reactContainersRef.current, { disabled, readOnly }); // Position cursor immediately to avoid flicker + // Only if element has focus to avoid stealing focus requestAnimationFrame(() => { - if (elementRef.current) { + if (elementRef.current && document.activeElement === elementRef.current) { setCursorPosition(elementRef.current, position); } }); @@ -359,8 +360,11 @@ export function useEditableTokens({ targetPosition = cursorPositionToRestore; } - // Unified restoration: set cursor to target position - setCursorPosition(elementRef.current, targetPosition); + // Unified restoration: only restore if element has focus + // This prevents stealing focus from other elements + if (document.activeElement === elementRef.current) { + setCursorPosition(elementRef.current, targetPosition); + } }) ); From d602dd08affd61a849142fd3bc07675598cd78b4 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Fri, 6 Mar 2026 09:59:09 +0100 Subject: [PATCH 4/9] Bug fixes and code improvements --- pages/prompt-input/shortcuts.page.tsx | 17 +- pages/webpack.config.base.cjs | 1 + .../__snapshots__/documenter.test.ts.snap | 104 ++-- src/internal/vendor/react-dom-client-stub.ts | 23 + src/prompt-input/components/token-mode.tsx | 6 +- src/prompt-input/core/cursor-manager.ts | 8 +- src/prompt-input/core/cursor-utils.ts | 211 +++++++ src/prompt-input/core/dom-utils.ts | 133 +++++ src/prompt-input/core/event-handlers.ts | 518 ++++-------------- src/prompt-input/core/token-engine.ts | 218 -------- src/prompt-input/core/token-extractor.ts | 216 -------- src/prompt-input/core/token-operations.ts | 415 ++++++++++++++ src/prompt-input/core/token-renderer.tsx | 53 +- src/prompt-input/core/token-utils.ts | 342 ++++++++++++ src/prompt-input/core/utils.ts | 195 ------- src/prompt-input/interfaces.ts | 14 + src/prompt-input/internal.tsx | 112 ++-- src/prompt-input/shortcuts/use-shortcuts.ts | 7 +- src/prompt-input/styles.scss | 5 + .../tokens/use-editable-tokens.ts | 266 +++++++-- .../utils/insert-text-content-editable.ts | 154 +----- tsconfig.json | 5 +- 22 files changed, 1692 insertions(+), 1331 deletions(-) create mode 100644 src/internal/vendor/react-dom-client-stub.ts create mode 100644 src/prompt-input/core/cursor-utils.ts create mode 100644 src/prompt-input/core/dom-utils.ts delete mode 100644 src/prompt-input/core/token-engine.ts delete mode 100644 src/prompt-input/core/token-extractor.ts create mode 100644 src/prompt-input/core/token-operations.ts create mode 100644 src/prompt-input/core/token-utils.ts delete mode 100644 src/prompt-input/core/utils.ts diff --git a/pages/prompt-input/shortcuts.page.tsx b/pages/prompt-input/shortcuts.page.tsx index 720da2588f..3911977d11 100644 --- a/pages/prompt-input/shortcuts.page.tsx +++ b/pages/prompt-input/shortcuts.page.tsx @@ -223,6 +223,7 @@ export default function PromptInputShortcutsPage() { trigger: '@', options: mentionOptions, filteringType: 'auto', + empty: 'No mentions found', }, { id: 'mode', @@ -230,12 +231,14 @@ export default function PromptInputShortcutsPage() { options: commandOptions, filteringType: 'auto', useAtStart: true, + empty: 'No commands found', }, { id: 'topics', trigger: '#', options: topicOptions, filteringType: 'auto', + empty: 'No topics found', }, ]; @@ -575,7 +578,19 @@ export default function PromptInputShortcutsPage() { onFilesChange={({ detail }) => detail.id.includes('files') && setFiles(detail.files)} onItemClick={({ detail }) => { if (detail.id === 'slash') { - ref.current?.insertText('/', 0); + // Filter out only pinned references to check content after them + const nonPinnedTokens = tokens.filter( + token => !(token.type === 'reference' && token.pinned) + ); + + // Determine if we need to add space before slash + let needsSpace = false; + if (nonPinnedTokens.length > 0) { + const firstToken = nonPinnedTokens[0]; + needsSpace = firstToken.type !== 'text' || !firstToken.value.startsWith(' '); + } + + ref.current?.insertText(needsSpace ? '/ ' : '/', 0, needsSpace ? 1 : undefined); } if (detail.id === 'at') { ref.current?.insertText('@'); diff --git a/pages/webpack.config.base.cjs b/pages/webpack.config.base.cjs index f359318eb4..9350296f59 100644 --- a/pages/webpack.config.base.cjs +++ b/pages/webpack.config.base.cjs @@ -51,6 +51,7 @@ module.exports = ({ } : { '~mount': path.resolve(__dirname, './app/mount/react16.ts'), + 'react-dom/client': path.resolve(__dirname, '../lib/components/internal/vendor/react-dom-client-stub.js'), }), }, }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 170a9a3787..b0595953d6 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -19703,7 +19703,9 @@ Use this to filter the options based on the filtering text. The detail object contains: - \`menuId\` - The ID of the menu that triggered the event. -- \`filteringText\` - The text to use for filtering options.", +- \`filteringText\` - The text to use for filtering options. + +Requires React 18.", "detailInlineType": { "name": "PromptInputProps.MenuFilterDetail", "properties": [ @@ -19725,7 +19727,9 @@ The detail object contains: }, { "cancelable": false, - "description": "Called whenever a user selects an option in a menu.", + "description": "Called whenever a user selects an option in a menu. + +Requires React 18.", "detailInlineType": { "name": "PromptInputProps.MenuItemSelectDetail", "properties": [ @@ -19986,7 +19990,9 @@ The detail object contains the following properties: - \`menuId\` - The ID of the menu that triggered the event. - \`filteringText\` - The value to use to fetch options (undefined for pagination). - \`firstPage\` - Indicates that you should fetch the first page of options. -- \`samePage\` - Indicates that you should fetch the same page (for example, when clicking recovery button).", +- \`samePage\` - Indicates that you should fetch the same page (for example, when clicking recovery button). + +Requires React 18.", "detailInlineType": { "name": "PromptInputProps.MenuLoadItemsDetail", "properties": [ @@ -20416,9 +20422,52 @@ receive focus.", "type": "string", }, { - "name": "selectedMenuItemAriaLabel", + "inlineType": { + "name": "(token: { label?: string | undefined; value: string; }) => string", + "parameters": [ + { + "name": "token", + "type": "{ label?: string | undefined; value: string; }", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenInsertedAriaLabel", "optional": true, - "type": "string", + "type": "((token: { label?: string | undefined; value: string; }) => string)", + }, + { + "inlineType": { + "name": "(token: { label?: string | undefined; value: string; }) => string", + "parameters": [ + { + "name": "token", + "type": "{ label?: string | undefined; value: string; }", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenPinnedAriaLabel", + "optional": true, + "type": "((token: { label?: string | undefined; value: string; }) => string)", + }, + { + "inlineType": { + "name": "(token: { label?: string | undefined; value: string; }) => string", + "parameters": [ + { + "name": "token", + "type": "{ label?: string | undefined; value: string; }", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "tokenRemovedAriaLabel", + "optional": true, + "type": "((token: { label?: string | undefined; value: string; }) => string)", }, ], "type": "object", @@ -20448,7 +20497,9 @@ single form field.", }, { "description": "Maximum height of the menu dropdown in pixels. -When not specified, the menu will grow to fit its content.", +When not specified, the menu will grow to fit its content. + +Requires React 18.", "name": "maxMenuHeight", "optional": true, "type": "number", @@ -20463,7 +20514,9 @@ Defaults to 3. Use -1 for infinite rows.", }, { "description": "Menus that can be triggered via specific symbols (e.g., "/" or "@"). -For menus only relevant to triggers at the start of the input, set \`useAtStart: true\`, defaults to \`false\`.", +For menus only relevant to triggers at the start of the input, set \`useAtStart: true\`, defaults to \`false\`. + +Requires React 18.", "name": "menus", "optional": true, "type": "Array", @@ -20520,35 +20573,6 @@ Don't use read-only inputs outside a form.", "optional": true, "type": "boolean", }, - { - "description": "Overrides the element that is announced to screen readers in menus -when the highlighted option changes. By default, this announces -the option's name and properties, and its selected state if -the \`selectedLabel\` property is defined. -The highlighted option is provided, and its group (if groups -are used and it differs from the group of the previously highlighted option). - -For more information, see the -[accessibility guidelines](/components/prompt-input/?tabId=usage#accessibility-guidelines).", - "inlineType": { - "name": "AutosuggestProps.ContainingOptionAndGroupString", - "parameters": [ - { - "name": "option", - "type": "OptionDefinition", - }, - { - "name": "group", - "type": "AutosuggestProps.OptionGroup", - }, - ], - "returnType": "string", - "type": "function", - }, - "name": "renderHighlightedMenuItemAriaLive", - "optional": true, - "type": "AutosuggestProps.ContainingOptionAndGroupString", - }, { "description": "Specifies the value of the \`spellcheck\` attribute on the native control. This value controls the native browser capability to check for spelling/grammar errors. @@ -20800,7 +20824,9 @@ All tokens use the same unified structure with a \`value\` property: - Reference tokens: \`value\` contains the reference value, \`label\` for display (e.g., '@john') - Trigger tokens: \`value\` contains the filter text, \`triggerChar\` for the trigger character -When \`menus\` is defined, you should use \`tokens\` to control the content instead of \`value\`.", +When \`menus\` is defined, you should use \`tokens\` to control the content instead of \`value\`. + +Requires React 18.", "name": "tokens", "optional": true, "type": "ReadonlyArray", @@ -20816,7 +20842,9 @@ tokens.map(token => token.value).join(''); Use this to customize serialization, for example: - Using \`label\` instead of \`value\` for reference tokens -- Adding custom formatting or separators between tokens", +- Adding custom formatting or separators between tokens + +Requires React 18.", "inlineType": { "name": "(tokens: ReadonlyArray) => string", "parameters": [ diff --git a/src/internal/vendor/react-dom-client-stub.ts b/src/internal/vendor/react-dom-client-stub.ts new file mode 100644 index 0000000000..8a2a29357e --- /dev/null +++ b/src/internal/vendor/react-dom-client-stub.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Stub for react-dom/client when React 18 is not available +// This allows the build to pass for React 16/17 while token mode features are disabled + +export interface Root { + render: (element: any) => void; + unmount: () => void; +} + +// Stub createRoot that does nothing (token mode won't work in React 16/17) +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function createRoot(_container?: HTMLElement): Root { + return { + render: () => { + // No-op in React 16/17 + }, + unmount: () => { + // No-op in React 16/17 + }, + }; +} diff --git a/src/prompt-input/components/token-mode.tsx b/src/prompt-input/components/token-mode.tsx index 49ccb60cfa..cf425b9481 100644 --- a/src/prompt-input/components/token-mode.tsx +++ b/src/prompt-input/components/token-mode.tsx @@ -89,9 +89,9 @@ export default function TokenMode({ role="textbox" aria-multiline="true" contentEditable={ - (!editableElementAttributes['aria-disabled'] && !editableElementAttributes['aria-readonly'] + !editableElementAttributes['aria-disabled'] && !editableElementAttributes['aria-readonly'] ? 'true' - : 'false') as any + : 'false' } suppressContentEditableWarning={true} className={testutilStyles['content-editable']} @@ -112,7 +112,7 @@ export default function TokenMode({ triggerWrapperReady && menuIsOpen && menuItemsState && - menuItemsState.items.length > 0 + (menuItemsState.items.length > 0 || menuDropdownStatus?.content) ) } trigger={null} diff --git a/src/prompt-input/core/cursor-manager.ts b/src/prompt-input/core/cursor-manager.ts index c50e5f4c93..74def54568 100644 --- a/src/prompt-input/core/cursor-manager.ts +++ b/src/prompt-input/core/cursor-manager.ts @@ -3,8 +3,8 @@ import type { PromptInputProps } from '../interfaces'; import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { findAllParagraphs, findElement, getTokenType } from './dom-utils'; import { isBreakToken, isHTMLElement, isTextNode, isTextToken, isTriggerToken } from './type-guards'; -import { findAllParagraphs, getTokenType } from './utils'; // HELPER FUNCTIONS @@ -106,8 +106,8 @@ function countUpToCursor(p: Element, range: Range): number { count += range.startOffset; } } else if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { - const cursorSpotBefore = child.querySelector(`[data-type="${ELEMENT_TYPES.CURSOR_SPOT_BEFORE}"]`); - const cursorSpotAfter = child.querySelector(`[data-type="${ELEMENT_TYPES.CURSOR_SPOT_AFTER}"]`); + const cursorSpotBefore = findElement(child, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }); + const cursorSpotAfter = findElement(child, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_AFTER }); const cursorInBefore = cursorSpotBefore && @@ -307,7 +307,7 @@ export function getTokenCursorLength(token: PromptInputProps.InputToken): number return token.value.length; } if (isBreakToken(token)) { - return 0; + return 1; // Line break counts as 1 position } if (isTriggerToken(token)) { return 1 + token.value.length; // trigger char + value diff --git a/src/prompt-input/core/cursor-utils.ts b/src/prompt-input/core/cursor-utils.ts new file mode 100644 index 0000000000..b9eb0afe55 --- /dev/null +++ b/src/prompt-input/core/cursor-utils.ts @@ -0,0 +1,211 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; +import { EditableState } from '../tokens/use-editable-tokens'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { getCursorPosition, getTokenCursorLength, setCursorPosition } from './cursor-manager'; +import { findElements, getTokenType, insertAfter } from './dom-utils'; +import { isTextNode } from './type-guards'; + +declare global { + interface Window { + isMouseDown?: boolean; + isMouseDownForCursor?: boolean; + } +} + +export interface CursorSpotExtractionResult { + movedTextNode: Text | null; +} + +export function extractTextFromCursorSpots( + paragraphs: HTMLElement[], + trackCursor: boolean = true +): CursorSpotExtractionResult { + let movedTextNode: Text | null = null; + + paragraphs.forEach(p => { + const cursorSpots = findElements(p, { + tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER], + }); + + cursorSpots.forEach(spot => { + const content = spot.textContent || ''; + const cleanContent = content.replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + + if (cleanContent) { + let cursorWasHere = false; + if (trackCursor) { + const selection = window.getSelection(); + if (selection?.rangeCount) { + const range = selection.getRangeAt(0); + if (spot.contains(range.startContainer)) { + cursorWasHere = true; + } + } + } + + const textNode = document.createTextNode(cleanContent); + const wrapper = spot.parentElement; + + if (wrapper) { + if (spot.getAttribute('data-type') === ELEMENT_TYPES.CURSOR_SPOT_BEFORE) { + wrapper.parentNode?.insertBefore(textNode, wrapper); + } else { + insertAfter(textNode, wrapper); + } + } + + if (cursorWasHere) { + movedTextNode = textNode; + } + } + + spot.textContent = SPECIAL_CHARS.ZWNJ; + }); + }); + + return { movedTextNode }; +} + +export function positionCursorAfterMovedText( + movedTextNode: Text, + element: HTMLElement, + lastKnownCursorPositionRef: React.MutableRefObject +): void { + const range = document.createRange(); + range.setStart(movedTextNode, movedTextNode.textContent?.length || 0); + range.collapse(true); + + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + + const newPos = getCursorPosition(element); + lastKnownCursorPositionRef.current = newPos; +} + +export function setCursorOverride(state: EditableState, position: number, paragraphId: string | null = null): void { + state.cursorPositionOverride = { cursorPosition: position, paragraphId }; + state.skipCursorRestore = false; +} + +export function applySafariCursorFix(element: HTMLDivElement, state: EditableState, position: number): void { + if (state.isDeleteOperation) { + state.isDeleteOperation = false; + setCursorPosition(element, position); + + // Collapse selection to force Safari to update cursor rendering + // This avoids screenreader disruption from blur/focus + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + selection.collapse(range.startContainer, range.startOffset); + } + } +} + +export function calculateTokenPosition( + tokens: readonly PromptInputProps.InputToken[], + targetIndex: number, + includeTarget: boolean = false +): number { + let position = 0; + const endIndex = includeTarget ? targetIndex : targetIndex - 1; + + for (let i = 0; i <= endIndex && i < tokens.length; i++) { + position += getTokenCursorLength(tokens[i]); + } + + return position; +} + +export function calculateEndPosition(tokens: readonly PromptInputProps.InputToken[]): number { + return tokens.reduce((sum, token) => sum + getTokenCursorLength(token), 0); +} + +export function getCurrentSelection(): Selection | null { + return window.getSelection(); +} + +export function getFirstRange(): Range | null { + const selection = getCurrentSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + return selection.getRangeAt(0); +} + +export function selectAllContent(element: HTMLElement): void { + const selection = getCurrentSelection(); + if (!selection) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(element); + + selection.removeAllRanges(); + selection.addRange(range); +} + +export function normalizeSelection(selection: Selection | null, skipCursorSpots: boolean = false): void { + if (!selection?.rangeCount) { + return; + } + + const range = selection.getRangeAt(0); + + if (range.collapsed || window.isMouseDown || skipCursorSpots) { + return; + } + + const normalizeBoundary = (container: Node) => { + if (!isTextNode(container)) { + return null; + } + + const parent = container.parentElement; + if (!parent) { + return null; + } + + const parentType = getTokenType(parent); + if (parentType !== ELEMENT_TYPES.CURSOR_SPOT_BEFORE && parentType !== ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + return null; + } + + const wrapper = parent.parentElement; + const wrapperType = wrapper ? getTokenType(wrapper) : null; + if (!wrapper || (wrapperType !== ELEMENT_TYPES.REFERENCE && wrapperType !== ELEMENT_TYPES.PINNED)) { + return null; + } + + const paragraph = wrapper.parentElement; + if (!paragraph) { + return null; + } + + const wrapperIndex = Array.from(paragraph.childNodes).indexOf(wrapper); + const newOffset = parentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE ? wrapperIndex : wrapperIndex + 1; + + return { container: paragraph, offset: newOffset }; + }; + + const normalizedStart = normalizeBoundary(range.startContainer); + const normalizedEnd = normalizeBoundary(range.endContainer); + + if (normalizedStart || normalizedEnd) { + const updatedRange = document.createRange(); + updatedRange.setStart( + normalizedStart?.container ?? range.startContainer, + normalizedStart?.offset ?? range.startOffset + ); + updatedRange.setEnd(normalizedEnd?.container ?? range.endContainer, normalizedEnd?.offset ?? range.endOffset); + selection.removeAllRanges(); + selection.addRange(updatedRange); + } +} diff --git a/src/prompt-input/core/dom-utils.ts b/src/prompt-input/core/dom-utils.ts new file mode 100644 index 0000000000..85a5a4d53f --- /dev/null +++ b/src/prompt-input/core/dom-utils.ts @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ELEMENT_TYPES } from './constants'; + +import styles from '../styles.css.js'; + +export function getTokenType(element: HTMLElement): string | null { + return element.getAttribute('data-type'); +} + +export function insertAfter(newNode: Node, referenceNode: Node): void { + const parent = referenceNode.parentNode; + if (!parent) { + return; + } + + if (referenceNode.nextSibling) { + parent.insertBefore(newNode, referenceNode.nextSibling); + } else { + parent.appendChild(newNode); + } +} + +export function createParagraph(): HTMLParagraphElement { + const p = document.createElement('p'); + p.className = styles.paragraph || 'paragraph'; + p.setAttribute('data-paragraph-id', generateTokenId('p')); + return p; +} + +export function createTrailingBreak(): HTMLBRElement { + const br = document.createElement('br'); + br.setAttribute('data-id', ELEMENT_TYPES.TRAILING_BREAK); + return br; +} + +export function generateTokenId(prefix: string): string { + return `${prefix}-${Date.now()}`; +} + +interface TokenQueryOptions { + tokenType?: string | string[]; + tokenId?: string; +} + +function buildTokenSelector(options: TokenQueryOptions): string { + const { tokenType, tokenId } = options; + + let selector = ''; + + if (tokenType) { + const types = Array.isArray(tokenType) ? tokenType : [tokenType]; + selector = types.map(type => `[data-type="${type}"]`).join(', '); + } + + if (tokenId) { + selector += `[data-id="${tokenId}"]`; + } + + return selector; +} + +export function findElements(container: HTMLElement, options: TokenQueryOptions): HTMLElement[] { + const selector = buildTokenSelector(options); + return selector ? Array.from(container.querySelectorAll(selector)) : []; +} + +export function findElement(container: HTMLElement, options: TokenQueryOptions): HTMLElement | null { + const selector = buildTokenSelector(options); + return selector ? container.querySelector(selector) : null; +} + +export function findAllParagraphs(container: HTMLElement): HTMLParagraphElement[] { + return Array.from(container.querySelectorAll('p')); +} + +export function isElementEffectivelyEmpty(element: HTMLElement): boolean { + if (element.childNodes.length === 0) { + return true; + } + + for (const child of Array.from(element.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + if (child.textContent && child.textContent.trim() !== '') { + return false; + } + } else { + return false; + } + } + return true; +} + +export function hasOnlyTrailingBR(paragraph: HTMLElement): boolean { + return paragraph.childNodes.length === 1 && paragraph.firstChild?.nodeName === 'BR'; +} + +export function isEmptyState(element: HTMLElement): boolean { + const paragraphs = findAllParagraphs(element); + return paragraphs.length === 0 || (paragraphs.length === 1 && hasOnlyTrailingBR(paragraphs[0])); +} + +export function ensureValidEmptyState(element: HTMLElement): void { + const paragraphs = findAllParagraphs(element); + + if (paragraphs.length === 0) { + const p = createParagraph(); + p.appendChild(createTrailingBreak()); + element.appendChild(p); + } else if (paragraphs.length === 1) { + const p = paragraphs[0]; + + if (hasOnlyTrailingBR(p)) { + return; + } + + while (p.firstChild) { + p.removeChild(p.firstChild); + } + p.appendChild(createTrailingBreak()); + } else { + while (paragraphs.length > 1) { + paragraphs[paragraphs.length - 1].remove(); + paragraphs.pop(); + } + const p = paragraphs[0]; + while (p.firstChild) { + p.removeChild(p.firstChild); + } + p.appendChild(createTrailingBreak()); + } +} diff --git a/src/prompt-input/core/event-handlers.ts b/src/prompt-input/core/event-handlers.ts index eabf9c9791..2182f43a38 100644 --- a/src/prompt-input/core/event-handlers.ts +++ b/src/prompt-input/core/event-handlers.ts @@ -2,11 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { PromptInputProps } from '../interfaces'; +import { EditableState } from '../tokens/use-editable-tokens'; import { ELEMENT_TYPES } from './constants'; import { getTokenCursorLength, positionAfter, positionBefore } from './cursor-manager'; -import { MenuItemsHandlers, MenuItemsState } from './menu-state'; -import { extractTokensFromDOM } from './token-extractor'; -import { isBRElement, isHTMLElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; +import { calculateTokenPosition, setCursorOverride } from './cursor-utils'; import { createParagraph, createTrailingBreak, @@ -14,10 +13,16 @@ import { getTokenType, insertAfter, isElementEffectivelyEmpty, -} from './utils'; +} from './dom-utils'; +import { MenuItemsHandlers, MenuItemsState } from './menu-state'; +import { extractTokensFromDOM, getPromptText } from './token-operations'; +import { findAdjacentToken } from './token-utils'; +import { isBreakToken, isHTMLElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; // TYPES +export type { EditableState }; + export interface KeyboardHandlerDeps { getMenuOpen: () => boolean; getMenuItemsState: () => MenuItemsState | null; @@ -25,40 +30,11 @@ export interface KeyboardHandlerDeps { onAction?: (detail: PromptInputProps.ActionDetail) => void; tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; tokens?: readonly PromptInputProps.InputToken[]; - getPromptText: (tokens: readonly PromptInputProps.InputToken[]) => string; closeMenu: () => void; announceTokenOperation?: (message: string) => void; i18nStrings?: PromptInputProps.I18nStrings; -} - -interface DeletionContext { - cursorPosition: number; - paragraphId: string | null; -} - -/** - * Shared state for coordinating between event handlers and input processing - */ -export interface EditableState { - skipNextZwnjUpdate: boolean; - skipNormalization: boolean; - skipCursorRestore: boolean; - targetParagraphId: string | null; - deletionContext: DeletionContext | null; - menuSelectionTokenId: string | null; - menuSelectionIsPinned: boolean; -} - -export function createEditableState(): EditableState { - return { - skipNextZwnjUpdate: false, - skipNormalization: false, - skipCursorRestore: false, - targetParagraphId: null, - deletionContext: null, - menuSelectionTokenId: null, - menuSelectionIsPinned: false, - }; + disabled?: boolean; + readOnly?: boolean; } // KEYBOARD HANDLERS @@ -100,6 +76,12 @@ export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { return; } + // Don't submit if disabled or readonly (match textarea behavior) + if (deps.disabled || deps.readOnly) { + event.preventDefault(); + return; + } + const currentTarget = event.currentTarget; if (!isHTMLElement(currentTarget)) { return; @@ -111,7 +93,7 @@ export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { } event.preventDefault(); - const plainText = deps.tokensToText ? deps.tokensToText(deps.tokens ?? []) : deps.getPromptText(deps.tokens ?? []); + const plainText = deps.tokensToText ? deps.tokensToText(deps.tokens ?? []) : getPromptText(deps.tokens ?? []); if (deps.onAction) { deps.onAction({ value: plainText, tokens: [...(deps.tokens ?? [])] }); @@ -124,156 +106,6 @@ export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { }; } -// PARAGRAPH MERGING - -export function handleBackspaceAtParagraphStart( - event: React.KeyboardEvent, - editableElement: HTMLDivElement, - tokens: readonly PromptInputProps.InputToken[], - tokensToText: ((tokens: readonly PromptInputProps.InputToken[]) => string) | undefined, - getPromptText: (tokens: readonly PromptInputProps.InputToken[]) => string, - onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, - setCursorPosition: (element: HTMLElement, position: number) => void, - state?: EditableState -): boolean { - const selection = window.getSelection(); - if (!selection?.rangeCount) { - return false; - } - - const range = selection.getRangeAt(0); - - if (range.startOffset !== 0 || range.startContainer.nodeName !== 'P') { - return false; - } - - const paragraphs = findAllParagraphs(editableElement); - const currentP = range.startContainer; - const pIndex = Array.from(paragraphs).indexOf(currentP as HTMLParagraphElement); - - if (pIndex <= 0) { - return false; - } - - event.preventDefault(); - - let breakCount = 0; - let cursorPosition = 0; - - const newTokens = tokens.filter(token => { - if (token.type === 'break') { - breakCount++; - if (breakCount === pIndex) { - return false; - } - cursorPosition += 1; - } else { - if (breakCount < pIndex) { - cursorPosition += getTokenCursorLength(token); - } - } - return true; - }); - - const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); - onChange({ value, tokens: newTokens }); - - // Store the target position for restoration after re-render - if (state) { - state.deletionContext = { - cursorPosition, - paragraphId: null, - }; - state.skipCursorRestore = false; - } else { - // Fallback for backward compatibility - requestAnimationFrame(() => { - setCursorPosition(editableElement, cursorPosition); - }); - } - - return true; -} - -export function handleDeleteAtParagraphEnd( - event: React.KeyboardEvent, - editableElement: HTMLDivElement, - tokens: readonly PromptInputProps.InputToken[], - tokensToText: ((tokens: readonly PromptInputProps.InputToken[]) => string) | undefined, - getPromptText: (tokens: readonly PromptInputProps.InputToken[]) => string, - cursorPosition: number, - onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, - setCursorPosition: (element: HTMLElement, position: number) => void, - state?: EditableState -): boolean { - const selection = window.getSelection(); - if (!selection?.rangeCount) { - return false; - } - - const range = selection.getRangeAt(0); - const container = range.startContainer; - - let isAtEndOfParagraph = false; - let currentP: HTMLParagraphElement | null = null; - - if (container.nodeName === 'P') { - currentP = container as HTMLParagraphElement; - const hasOnlyTrailingBR = - currentP.childNodes.length === 1 && isBRElement(currentP.firstChild, ELEMENT_TYPES.TRAILING_BREAK); - isAtEndOfParagraph = hasOnlyTrailingBR || range.startOffset === currentP.childNodes.length; - } else if (isTextNode(container)) { - isAtEndOfParagraph = range.startOffset === (container.textContent?.length || 0) && !container.nextSibling; - let node: Node | null = container; - while (node && node.nodeName !== 'P') { - node = node.parentNode; - } - currentP = node as HTMLParagraphElement; - } - - if (!isAtEndOfParagraph || !currentP) { - return false; - } - - const paragraphs = findAllParagraphs(editableElement); - const pIndex = Array.from(paragraphs).indexOf(currentP); - - if (pIndex < 0 || pIndex >= paragraphs.length - 1) { - return false; - } - - event.preventDefault(); - - let breakCount = 0; - - const newTokens = tokens.filter(token => { - if (token.type === 'break') { - breakCount++; - return breakCount !== pIndex + 1; - } - return true; - }); - - const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); - onChange({ value, tokens: newTokens }); - - // Store the target position for restoration after re-render - if (state) { - state.deletionContext = { - cursorPosition, - paragraphId: null, - }; - state.skipCursorRestore = false; - } else { - // Fallback for backward compatibility - requestAnimationFrame(() => { - setCursorPosition(editableElement, cursorPosition); - }); - } - - return true; -} - // PARAGRAPH OPERATIONS function findParagraphAncestor(node: Node): HTMLElement | null { @@ -301,14 +133,17 @@ export function splitParagraphAtCursor( return; } + // Extract content after cursor const afterRange = document.createRange(); afterRange.setStart(range.startContainer, range.startOffset); afterRange.setEndAfter(currentP.lastChild || currentP); const afterContent = afterRange.extractContents(); + // Create new paragraph with the extracted content const newP = createParagraph(); newP.appendChild(afterContent); + // Ensure both paragraphs have proper structure if (isElementEffectivelyEmpty(newP)) { newP.appendChild(createTrailingBreak()); } @@ -320,7 +155,6 @@ export function splitParagraphAtCursor( currentP.parentNode.insertBefore(newP, currentP.nextSibling); // Calculate cursor position for the new paragraph (at its start) - // Count all tokens before the split point const paragraphs = findAllParagraphs(editableElement); const currentPIndex = paragraphs.findIndex(p => p === currentP); @@ -329,7 +163,7 @@ export function splitParagraphAtCursor( let breakCount = 0; for (const token of tokens) { - if (token.type === 'break') { + if (isBreakToken(token)) { breakCount++; cursorPosition += 1; if (breakCount > currentPIndex) { @@ -342,8 +176,7 @@ export function splitParagraphAtCursor( state.skipCursorRestore = false; state.targetParagraphId = newP.getAttribute('data-paragraph-id'); - // Store the calculated position for unified restoration - state.deletionContext = { + state.cursorPositionOverride = { cursorPosition, paragraphId: newP.getAttribute('data-paragraph-id'), }; @@ -360,47 +193,25 @@ interface TokenElementResult { wrapperElement: HTMLElement | null; } -function findTokenElementForBackspace(container: Node, offset: number): TokenElementResult { - if (isTextNode(container) && offset === 0) { - const prev = container.previousSibling; - const prevType = isHTMLElement(prev) ? getTokenType(prev) : null; - if (prevType === ELEMENT_TYPES.REFERENCE || prevType === ELEMENT_TYPES.PINNED) { - return { - wrapperElement: prev as HTMLElement, - targetElement: prev as HTMLElement, - }; - } - } else if (isHTMLElement(container) && offset > 0) { - const prev = container.childNodes[offset - 1]; - const prevType = isHTMLElement(prev) ? getTokenType(prev) : null; - if (prevType === ELEMENT_TYPES.REFERENCE || prevType === ELEMENT_TYPES.PINNED) { - return { - wrapperElement: prev as HTMLElement, - targetElement: prev as HTMLElement, - }; - } - } - - return { targetElement: null, wrapperElement: null }; -} +function findTokenElementForDeletion(container: Node, offset: number, isBackspace: boolean): TokenElementResult { + let adjacent: Node | null = null; -function findTokenElementForDelete(container: Node, offset: number): TokenElementResult { - if (isTextNode(container) && offset === (container.textContent?.length || 0)) { - const next = container.nextSibling; - const nextType = isHTMLElement(next) ? getTokenType(next) : null; - if (nextType === ELEMENT_TYPES.REFERENCE || nextType === ELEMENT_TYPES.PINNED) { - return { - wrapperElement: next as HTMLElement, - targetElement: next as HTMLElement, - }; + if (isTextNode(container)) { + const isAtEdge = isBackspace ? offset === 0 : offset === (container.textContent?.length || 0); + if (isAtEdge) { + adjacent = isBackspace ? container.previousSibling : container.nextSibling; } } else if (isHTMLElement(container)) { - const next = container.childNodes[offset]; - const nextType = isHTMLElement(next) ? getTokenType(next) : null; - if (nextType === ELEMENT_TYPES.REFERENCE || nextType === ELEMENT_TYPES.PINNED) { + const childIndex = isBackspace ? offset - 1 : offset; + adjacent = container.childNodes[childIndex]; + } + + if (isHTMLElement(adjacent)) { + const adjacentType = getTokenType(adjacent); + if (adjacentType === ELEMENT_TYPES.REFERENCE || adjacentType === ELEMENT_TYPES.PINNED) { return { - wrapperElement: next as HTMLElement, - targetElement: next as HTMLElement, + wrapperElement: adjacent, + targetElement: adjacent, }; } } @@ -437,27 +248,29 @@ export function handleReferenceTokenDeletion( return false; } - const { targetElement, wrapperElement } = isBackspace - ? findTokenElementForBackspace(range.startContainer, range.startOffset) - : findTokenElementForDelete(range.startContainer, range.startOffset); + const { targetElement, wrapperElement } = findTokenElementForDeletion( + range.startContainer, + range.startOffset, + isBackspace + ); - const finalTarget = targetElement || wrapperElement || null; + const tokenElement = targetElement || wrapperElement || null; - if (!isValidTokenForDeletion(finalTarget)) { + if (!isValidTokenForDeletion(tokenElement)) { return false; } event.preventDefault(); // Announce token removal - const tokenLabel = finalTarget!.textContent?.trim() || ''; + const tokenLabel = tokenElement!.textContent?.trim() || ''; if (announceTokenOperation && tokenLabel) { const announcement = i18nStrings?.tokenRemovedAriaLabel?.({ label: tokenLabel, value: tokenLabel }) ?? `${tokenLabel} removed`; announceTokenOperation(announcement); } - const elementToRemove = (wrapperElement || finalTarget)!; + const elementToRemove = (wrapperElement || tokenElement)!; const paragraph = elementToRemove.parentNode; if (!isHTMLElement(paragraph)) { return true; @@ -468,34 +281,19 @@ export function handleReferenceTokenDeletion( // Find the reference token's position in the token array // This gives us the correct position independent of DOM structure - const instanceId = finalTarget!.getAttribute('data-id'); + const instanceId = tokenElement!.getAttribute('data-id'); const tokens = extractTokensFromDOM(editableElement); const referenceIndex = tokens.findIndex(t => isReferenceToken(t) && t.id === instanceId); - let targetCursorPosition = 0; + let cursorPosition = 0; if (referenceIndex >= 0) { // Calculate position up to (but not including) the reference - for (let i = 0; i < referenceIndex; i++) { - const token = tokens[i]; - if (isTextToken(token)) { - targetCursorPosition += token.value.length; - } else if (isTriggerToken(token)) { - targetCursorPosition += 1 + token.value.length; - } else { - targetCursorPosition += 1; // other references - } - } - - // For delete, cursor stays before the reference (already calculated) - // For backspace, cursor also goes before the reference (same position) + cursorPosition = calculateTokenPosition(tokens, referenceIndex, false); } - // Store the target position for restoration after re-render - state.deletionContext = { - cursorPosition: targetCursorPosition, - paragraphId: null, - }; - state.skipCursorRestore = false; // Allow restoration with our calculated position + // Store the position for restoration after re-render + setCursorOverride(state, cursorPosition); + state.isDeleteOperation = true; // Mark as deletion for Safari ghost cursor fix elementToRemove.remove(); editableElement.dispatchEvent(new Event('input', { bubbles: true })); @@ -505,60 +303,19 @@ export function handleReferenceTokenDeletion( // ARROW KEY NAVIGATION -function handleArrowInElementNode( +function handleArrowNavigation( event: React.KeyboardEvent, container: Node, offset: number, skipNormalizationRef: React.MutableRefObject ): boolean { - if (!isHTMLElement(container)) { - return false; - } + const direction = event.key === 'ArrowLeft' ? 'left' : 'right'; + const { sibling, isReferenceToken } = findAdjacentToken(container, offset, direction); - const isLeftArrow = event.key === 'ArrowLeft'; - const sibling = isLeftArrow - ? offset > 0 - ? container.childNodes[offset - 1] - : container.previousSibling - : offset < container.childNodes.length - ? container.childNodes[offset] - : container.nextSibling; - - const siblingType = isHTMLElement(sibling) ? getTokenType(sibling) : null; - if (siblingType === ELEMENT_TYPES.REFERENCE || siblingType === ELEMENT_TYPES.PINNED) { - event.preventDefault(); - skipNormalizationRef.current = true; - isLeftArrow ? positionBefore(sibling as HTMLElement) : positionAfter(sibling as HTMLElement); - return true; - } - - return false; -} - -function handleArrowInTextNode( - event: React.KeyboardEvent, - container: Node, - offset: number, - skipNormalizationRef: React.MutableRefObject -): boolean { - if (!isTextNode(container)) { - return false; - } - - const isLeftArrow = event.key === 'ArrowLeft'; - const isAtBoundary = isLeftArrow ? offset === 0 : offset === (container.textContent?.length || 0); - - if (!isAtBoundary) { - return false; - } - - const sibling = isLeftArrow ? container.previousSibling : container.nextSibling; - - const siblingType = isHTMLElement(sibling) ? getTokenType(sibling) : null; - if (siblingType === ELEMENT_TYPES.REFERENCE || siblingType === ELEMENT_TYPES.PINNED) { + if (isReferenceToken && sibling) { event.preventDefault(); skipNormalizationRef.current = true; - isLeftArrow ? positionBefore(sibling as HTMLElement) : positionAfter(sibling as HTMLElement); + direction === 'left' ? positionBefore(sibling) : positionAfter(sibling); return true; } @@ -579,18 +336,13 @@ export function handleArrowKeyNavigation( } const range = selection.getRangeAt(0); - const container = range.startContainer; - const offset = range.startOffset; // Handle Shift+Arrow for selection across reference tokens if (event.shiftKey) { return handleShiftArrowAcrossTokens(event, selection, range); } - return ( - handleArrowInElementNode(event, container, offset, skipNormalizationRef) || - handleArrowInTextNode(event, container, offset, skipNormalizationRef) - ); + return handleArrowNavigation(event, range.startContainer, range.startOffset, skipNormalizationRef); } function handleShiftArrowAcrossTokens( @@ -717,100 +469,6 @@ export function createCursorNormalizationHandler( }; } -// SELECTION NORMALIZATION - -/** - * Normalizes selection to include entire reference tokens when selection boundary is in cursor spots. - * If selection starts or ends in a cursor spot, expands to include the entire reference wrapper. - */ -function normalizeSelectionAroundReferences(): void { - const selection = window.getSelection(); - if (!selection?.rangeCount) { - return; - } - - const range = selection.getRangeAt(0); - - // Only normalize non-collapsed selections - if (range.collapsed) { - return; - } - - let modified = false; - let newStartContainer = range.startContainer; - let newStartOffset = range.startOffset; - let newEndContainer = range.endContainer; - let newEndOffset = range.endOffset; - - // Check if start is in a cursor spot - if (isTextNode(range.startContainer)) { - const startParent = range.startContainer.parentElement; - if (startParent) { - const startParentType = getTokenType(startParent); - if (startParentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE || startParentType === ELEMENT_TYPES.CURSOR_SPOT_AFTER) { - const wrapper = startParent.parentElement; - const wrapperType = wrapper ? getTokenType(wrapper) : null; - if (wrapper && (wrapperType === ELEMENT_TYPES.REFERENCE || wrapperType === ELEMENT_TYPES.PINNED)) { - const paragraph = wrapper.parentElement; - if (paragraph) { - // If in cursor-spot-before, expand to before wrapper - // If in cursor-spot-after, expand to after wrapper - if (startParentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE) { - newStartContainer = paragraph; - newStartOffset = Array.from(paragraph.childNodes).indexOf(wrapper); - } else { - newStartContainer = paragraph; - newStartOffset = Array.from(paragraph.childNodes).indexOf(wrapper) + 1; - } - modified = true; - } - } - } - } - } - - // Check if end is in a cursor spot - if (isTextNode(range.endContainer)) { - const endParent = range.endContainer.parentElement; - if (endParent) { - const endParentType = getTokenType(endParent); - if (endParentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE || endParentType === ELEMENT_TYPES.CURSOR_SPOT_AFTER) { - const wrapper = endParent.parentElement; - const wrapperType = wrapper ? getTokenType(wrapper) : null; - if (wrapper && (wrapperType === ELEMENT_TYPES.REFERENCE || wrapperType === ELEMENT_TYPES.PINNED)) { - const paragraph = wrapper.parentElement; - if (paragraph) { - // If in cursor-spot-before, expand to before wrapper - // If in cursor-spot-after, expand to after wrapper - if (endParentType === ELEMENT_TYPES.CURSOR_SPOT_BEFORE) { - newEndContainer = paragraph; - newEndOffset = Array.from(paragraph.childNodes).indexOf(wrapper); - } else { - newEndContainer = paragraph; - newEndOffset = Array.from(paragraph.childNodes).indexOf(wrapper) + 1; - } - modified = true; - } - } - } - } - } - - if (modified) { - const newRange = document.createRange(); - newRange.setStart(newStartContainer, newStartOffset); - newRange.setEnd(newEndContainer, newEndOffset); - selection.removeAllRanges(); - selection.addRange(newRange); - } -} - -export function createSelectionNormalizationHandler(): () => void { - return () => { - normalizeSelectionAroundReferences(); - }; -} - // SPACE AFTER CLOSED TRIGGER export function handleSpaceAfterClosedTrigger( @@ -818,7 +476,8 @@ export function handleSpaceAfterClosedTrigger( editableElement: HTMLDivElement, menuOpen: boolean, triggerValueWhenClosed: string, - editableState: EditableState + editableState: EditableState, + menus?: readonly PromptInputProps.MenuDefinition[] ): boolean { // Only handle space key when menu is closed and we have a saved trigger length if (event.key !== ' ' || menuOpen || !triggerValueWhenClosed) { @@ -878,22 +537,43 @@ export function handleSpaceAfterClosedTrigger( insertAfter(spaceNode, triggerElement); // Calculate cursor position: after trigger + after space - const tokens = extractTokensFromDOM(editableElement); + const tokens = extractTokensFromDOM(editableElement, menus); + + // Find the trigger element's ID to locate the correct trigger token + const triggerElementId = triggerElement.getAttribute('data-id'); + let cursorPosition = 0; - let foundTrigger = false; + let foundTargetTrigger = false; for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; - if (token.type === 'trigger' && !foundTrigger) { - foundTrigger = true; - cursorPosition += getTokenCursorLength(token); + // Find the specific trigger that matches our trigger element + if (isTriggerToken(token) && !foundTargetTrigger) { + // Match by ID if available, otherwise by being the first unmatched trigger + if (triggerElementId && token.id === triggerElementId) { + foundTargetTrigger = true; + cursorPosition += getTokenCursorLength(token); - // Check if next token is the space we just inserted - const nextToken = tokens[i + 1]; - if (nextToken && nextToken.type === 'text' && nextToken.value.startsWith(' ')) { - cursorPosition += 1; // Position after the space - break; + // Check if next token is the space we just inserted + const nextToken = tokens[i + 1]; + if (nextToken && isTextToken(nextToken) && nextToken.value.startsWith(' ')) { + cursorPosition += 1; // Position after the space + break; + } + } else if (!triggerElementId) { + // Fallback: use first trigger + foundTargetTrigger = true; + cursorPosition += getTokenCursorLength(token); + + const nextToken = tokens[i + 1]; + if (nextToken && isTextToken(nextToken) && nextToken.value.startsWith(' ')) { + cursorPosition += 1; + break; + } + } else { + // Not the target trigger, keep counting + cursorPosition += getTokenCursorLength(token); } } else { cursorPosition += getTokenCursorLength(token); @@ -901,12 +581,24 @@ export function handleSpaceAfterClosedTrigger( } // Store position for unified restoration - editableState.deletionContext = { + editableState.cursorPositionOverride = { cursorPosition, paragraphId: null, }; editableState.skipCursorRestore = false; + // Position cursor immediately to prevent it from jumping to position 0 + // This prevents menu from flickering open + const cursorRange = document.createRange(); + const spaceTextNode = spaceNode; + cursorRange.setStart(spaceTextNode, 1); // After the space + cursorRange.collapse(true); + const sel = window.getSelection(); + if (sel) { + sel.removeAllRanges(); + sel.addRange(cursorRange); + } + // Trigger input event to extract tokens and update state editableElement.dispatchEvent(new Event('input', { bubbles: true })); diff --git a/src/prompt-input/core/token-engine.ts b/src/prompt-input/core/token-engine.ts deleted file mode 100644 index 63a976992b..0000000000 --- a/src/prompt-input/core/token-engine.ts +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { PromptInputProps } from '../interfaces'; -import { getCursorPositionAtIndex, getTokenCursorLength } from './cursor-manager'; -import { isPinnedReferenceToken, isReferenceToken, isTextToken, isTriggerToken } from './type-guards'; -import { generateTokenId } from './utils'; - -// TYPES - -export type UpdateSource = 'user-input' | 'external' | 'menu-selection' | 'internal'; - -export interface TokenUpdate { - tokens: PromptInputProps.InputToken[]; - source: UpdateSource; - cursorPosition?: number; -} - -export interface ShortcutsConfig { - menus?: readonly PromptInputProps.MenuDefinition[]; - tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; -} - -export interface MenuSelectionResult { - tokens: PromptInputProps.InputToken[]; - cursorPosition: number; - insertedToken: PromptInputProps.ReferenceToken; -} - -// HELPER FUNCTIONS - -function areAllTokensPinned(tokens: readonly PromptInputProps.InputToken[]): boolean { - return tokens.every(isPinnedReferenceToken); -} - -function isTriggerValid( - menu: PromptInputProps.MenuDefinition, - triggerIndex: number, - text: string, - precedingTokens: readonly PromptInputProps.InputToken[] -): boolean { - const isAtStart = triggerIndex === 0; - const charBefore = triggerIndex > 0 ? text[triggerIndex - 1] : ''; - const isAfterWhitespace = /\s/.test(charBefore); - - if (menu.useAtStart) { - return isAtStart && areAllTokensPinned(precedingTokens); - } - - return isAtStart || isAfterWhitespace; -} - -// TRIGGER DETECTION - -export function detectTriggersInText( - text: string, - menus: readonly PromptInputProps.MenuDefinition[], - precedingTokens: readonly PromptInputProps.InputToken[] -): PromptInputProps.InputToken[] { - const results: PromptInputProps.InputToken[] = []; - let position = 0; - - while (position < text.length) { - let foundTrigger = false; - - for (const menu of menus) { - const triggerIndex = text.indexOf(menu.trigger, position); - if (triggerIndex === -1) { - continue; - } - - if (!isTriggerValid(menu, triggerIndex, text, precedingTokens)) { - continue; - } - - const beforeTrigger = text.substring(position, triggerIndex); - if (beforeTrigger) { - results.push({ type: 'text', value: beforeTrigger }); - } - - const afterTrigger = text.substring(triggerIndex + menu.trigger.length); - let filterText = ''; - let remainingText = afterTrigger; - - if (afterTrigger && !/^\s/.test(afterTrigger)) { - let endIndex = 0; - while (endIndex < afterTrigger.length && !/\s/.test(afterTrigger[endIndex])) { - endIndex++; - } - filterText = afterTrigger.substring(0, endIndex); - remainingText = afterTrigger.substring(endIndex); - } - - results.push({ - type: 'trigger', - value: filterText, - triggerChar: menu.trigger, - id: generateTokenId('trigger'), - }); - - if (remainingText) { - results.push({ type: 'text', value: remainingText }); - } - - position = text.length; - foundTrigger = true; - break; - } - - if (!foundTrigger) { - const remaining = text.substring(position); - if (remaining) { - results.push({ type: 'text', value: remaining }); - } - break; - } - } - - return results.length > 0 ? results : [{ type: 'text', value: text }]; -} - -export function detectTriggersInTokens( - tokens: readonly PromptInputProps.InputToken[], - menus: readonly PromptInputProps.MenuDefinition[] -): PromptInputProps.InputToken[] { - const result: PromptInputProps.InputToken[] = []; - - for (const token of tokens) { - if (isTextToken(token)) { - const detectedTokens = detectTriggersInText(token.value, menus, result); - result.push(...detectedTokens); - } else { - result.push(token); - } - } - - return result; -} - -// MENU SELECTION - -export function handleMenuSelection( - tokens: readonly PromptInputProps.InputToken[], - selectedOption: { - value: string; - label?: string; - }, - menuId: string, - isPinned: boolean, - activeTrigger: PromptInputProps.TriggerToken -): MenuSelectionResult { - const newTokens = [...tokens]; - - const triggerIndex = newTokens.findIndex(t => isTriggerToken(t) && t.id === activeTrigger.id); - - if (isPinned) { - const pinnedToken: PromptInputProps.ReferenceToken = { - type: 'reference', - id: generateTokenId('ref'), - label: selectedOption.label || selectedOption.value || '', - value: selectedOption.value || '', - menuId, - pinned: true, - }; - - newTokens.splice(triggerIndex, 1); - - let insertIndex = 0; - while (insertIndex < newTokens.length && isPinnedReferenceToken(newTokens[insertIndex])) { - insertIndex++; - } - - newTokens.splice(insertIndex, 0, pinnedToken); - - const cursorPos = getCursorPositionAtIndex(newTokens, insertIndex); - return { tokens: newTokens, cursorPosition: cursorPos, insertedToken: pinnedToken }; - } else { - const referenceToken: PromptInputProps.ReferenceToken = { - type: 'reference', - id: generateTokenId('ref'), - label: selectedOption.label || selectedOption.value || '', - value: selectedOption.value || '', - menuId, - }; - - newTokens.splice(triggerIndex, 1, referenceToken); - - let cursorPos = 0; - for (const token of newTokens) { - cursorPos += getTokenCursorLength(token); - - if (isReferenceToken(token) && token.id === selectedOption.value) { - break; - } - } - - return { tokens: newTokens, cursorPosition: cursorPos, insertedToken: referenceToken }; - } -} - -// TOKEN PROCESSING - -export function processTokens( - tokens: readonly PromptInputProps.InputToken[], - config: ShortcutsConfig, - options: { - source: UpdateSource; - detectTriggers?: boolean; - } -): PromptInputProps.InputToken[] { - let result = [...tokens]; - - if (options.detectTriggers && config.menus) { - result = detectTriggersInTokens(result, config.menus); - } - - return result; -} diff --git a/src/prompt-input/core/token-extractor.ts b/src/prompt-input/core/token-extractor.ts deleted file mode 100644 index 573751d34a..0000000000 --- a/src/prompt-input/core/token-extractor.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { OptionDefinition, OptionGroup } from '../../internal/components/option/interfaces'; -import { PromptInputProps } from '../interfaces'; -import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; -import { isBRElement, isHTMLElement, isPinnedReferenceToken, isTextNode } from './type-guards'; -import { findAllParagraphs, findElement, generateTokenId, getTokenType } from './utils'; - -// HELPER FUNCTIONS - -function findOptionInMenu( - options: readonly (OptionDefinition | OptionGroup)[], - labelOrValue: string -): OptionDefinition | undefined { - for (const item of options) { - if ('options' in item) { - // It's a group, search in its options - const found = item.options?.find(opt => opt.value === labelOrValue || opt.label === labelOrValue); - if (found) { - return found; - } - } else if (item.value === labelOrValue || item.label === labelOrValue) { - // It's an option - return item; - } - } - return undefined; -} - -export function extractTokensFromDOM( - element: HTMLElement, - menus?: readonly PromptInputProps.MenuDefinition[] -): PromptInputProps.InputToken[] { - const paragraphs = findAllParagraphs(element); - - if (paragraphs.length === 0) { - return []; - } - - // Special case: single empty paragraph = empty input - if (paragraphs.length === 1) { - const p = paragraphs[0]; - const hasOnlyTrailingBr = p.childNodes.length === 1 && isBRElement(p.firstChild, ELEMENT_TYPES.TRAILING_BREAK); - - if (hasOnlyTrailingBr) { - return []; - } - } - - const allTokens: PromptInputProps.InputToken[] = []; - - paragraphs.forEach((p, pIndex) => { - const paragraphTokens = extractTokensFromParagraph(p, menus); - - if (pIndex > 0) { - allTokens.push({ type: 'break', value: SPECIAL_CHARS.NEWLINE }); - } - - allTokens.push(...paragraphTokens); - }); - - return allTokens; -} - -function extractTokensFromParagraph( - p: HTMLElement, - menus?: readonly PromptInputProps.MenuDefinition[] -): PromptInputProps.InputToken[] { - const tokens: PromptInputProps.InputToken[] = []; - let textBuffer = ''; - - const flushText = () => { - if (textBuffer) { - tokens.push({ type: 'text', value: textBuffer }); - textBuffer = ''; - } - }; - - const processNode = (node: Node) => { - if (isTextNode(node)) { - const text = (node.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); - if (text) { - textBuffer += text; - } - } else if (isHTMLElement(node)) { - if (node.tagName === 'BR') { - return; - } - - const tokenType = getTokenType(node); - - if (tokenType === ELEMENT_TYPES.TRIGGER) { - flushText(); - const id = node.getAttribute('data-id') || generateTokenId('trigger'); - const fullText = node.textContent || ''; - const triggerChar = fullText.charAt(0); - const value = fullText.substring(1); - - const token: PromptInputProps.TriggerToken = { - type: 'trigger', - value, - triggerChar, - id, - }; - tokens.push(token); - } else if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { - flushText(); - - const cursorSpotBefore = findElement(node, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }); - if (cursorSpotBefore) { - const beforeText = (cursorSpotBefore.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); - if (beforeText) { - tokens.push({ type: 'text', value: beforeText }); - } - } - - // Extract label from token's text content (excluding cursor spots) - let label = ''; - for (const child of Array.from(node.childNodes)) { - if (isTextNode(child)) { - label += child.textContent || ''; - } else if (isHTMLElement(child)) { - const childType = getTokenType(child); - if (childType !== ELEMENT_TYPES.CURSOR_SPOT_BEFORE && childType !== ELEMENT_TYPES.CURSOR_SPOT_AFTER) { - label += child.textContent || ''; - } - } - } - label = label.replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), '').trim(); - - const instanceId = node.getAttribute('data-id') || ''; - const menuId = node.getAttribute('data-menu-id') || ''; - - // Look up option from menu definition using the label - let value = ''; - if (menuId && menus && label) { - const menu = menus.find(m => m.id === menuId); - if (menu) { - const option = findOptionInMenu(menu.options, label); - if (option) { - value = option.value || ''; - label = option.label || option.value || label; - } - } - } - - const token: PromptInputProps.ReferenceToken = { - type: 'reference', - id: instanceId, - value, - label, - menuId, - }; - if (tokenType === ELEMENT_TYPES.PINNED) { - token.pinned = true; - } - tokens.push(token); - - const cursorSpotAfter = findElement(node, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_AFTER }); - if (cursorSpotAfter) { - const afterText = (cursorSpotAfter.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); - if (afterText) { - tokens.push({ type: 'text', value: afterText }); - } - } - } else { - Array.from(node.childNodes).forEach(processNode); - } - } - }; - - Array.from(p.childNodes).forEach(processNode); - flushText(); - - return tokens; -} - -export function getPromptText(tokens: readonly PromptInputProps.InputToken[]): string { - return tokens.map(token => token.value).join(''); -} - -export function findLastPinnedTokenIndex(tokens: readonly PromptInputProps.InputToken[]): number { - for (let i = tokens.length - 1; i >= 0; i--) { - if (isPinnedReferenceToken(tokens[i])) { - return i; - } - } - return -1; -} - -export function moveForbiddenTextAfterPinnedTokens( - tokens: readonly PromptInputProps.InputToken[] -): PromptInputProps.InputToken[] { - const lastPinnedIndex = findLastPinnedTokenIndex(tokens); - - if (lastPinnedIndex === -1) { - return [...tokens]; - } - - const pinnedTokens: PromptInputProps.InputToken[] = []; - const forbiddenContent: PromptInputProps.InputToken[] = []; - const allowedContent: PromptInputProps.InputToken[] = []; - - tokens.forEach((token, index) => { - if (isPinnedReferenceToken(token)) { - pinnedTokens.push(token); - } else if (index <= lastPinnedIndex) { - forbiddenContent.push(token); - } else { - allowedContent.push(token); - } - }); - - return [...pinnedTokens, ...forbiddenContent, ...allowedContent]; -} diff --git a/src/prompt-input/core/token-operations.ts b/src/prompt-input/core/token-operations.ts new file mode 100644 index 0000000000..ae4f51cf26 --- /dev/null +++ b/src/prompt-input/core/token-operations.ts @@ -0,0 +1,415 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OptionDefinition, OptionGroup } from '../../internal/components/option/interfaces'; +import type { PromptInputProps } from '../interfaces'; +import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; +import { getCursorPositionAtIndex, getTokenCursorLength } from './cursor-manager'; +import { findAllParagraphs, findElement, generateTokenId, getTokenType } from './dom-utils'; +import { detectTriggersInText } from './token-utils'; +import { + isBRElement, + isHTMLElement, + isPinnedReferenceToken, + isReferenceToken, + isTextNode, + isTextToken, + isTriggerToken, +} from './type-guards'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type UpdateSource = 'user-input' | 'external' | 'menu-selection' | 'internal'; + +export interface TokenUpdate { + tokens: PromptInputProps.InputToken[]; + source: UpdateSource; + cursorPosition?: number; +} + +export interface ShortcutsConfig { + menus?: readonly PromptInputProps.MenuDefinition[]; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; +} + +export interface MenuSelectionResult { + tokens: PromptInputProps.InputToken[]; + cursorPosition: number; + insertedToken: PromptInputProps.ReferenceToken; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +// DOM EXTRACTION HELPERS + +function findOptionInMenu( + options: readonly (OptionDefinition | OptionGroup)[], + labelOrValue: string +): OptionDefinition | undefined { + for (const item of options) { + if ('options' in item) { + // It's a group, search in its options + const found = item.options?.find(opt => opt.value === labelOrValue || opt.label === labelOrValue); + if (found) { + return found; + } + } else if (item.value === labelOrValue || item.label === labelOrValue) { + // It's an option + return item; + } + } + return undefined; +} + +export function extractTokensFromDOM( + element: HTMLElement, + menus?: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const paragraphs = findAllParagraphs(element); + + if (paragraphs.length === 0) { + return []; + } + + // Special case: single empty paragraph = empty input + if (paragraphs.length === 1) { + const p = paragraphs[0]; + const hasOnlyTrailingBr = p.childNodes.length === 1 && isBRElement(p.firstChild, ELEMENT_TYPES.TRAILING_BREAK); + + if (hasOnlyTrailingBr) { + return []; + } + } + + const allTokens: PromptInputProps.InputToken[] = []; + + paragraphs.forEach((p, pIndex) => { + const paragraphTokens = extractTokensFromParagraph(p, menus); + + if (pIndex > 0) { + allTokens.push({ type: 'break', value: SPECIAL_CHARS.NEWLINE }); + } + + allTokens.push(...paragraphTokens); + }); + + return allTokens; +} + +function extractTokensFromParagraph( + p: HTMLElement, + menus?: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const tokens: PromptInputProps.InputToken[] = []; + let textBuffer = ''; + + const flushText = () => { + if (textBuffer) { + tokens.push({ type: 'text', value: textBuffer }); + textBuffer = ''; + } + }; + + const processNode = (node: Node) => { + if (isTextNode(node)) { + const text = (node.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (text) { + textBuffer += text; + } + } else if (isHTMLElement(node)) { + if (node.tagName === 'BR') { + return; + } + + const tokenType = getTokenType(node); + + if (tokenType === ELEMENT_TYPES.TRIGGER) { + flushText(); + const id = node.getAttribute('data-id') || generateTokenId('trigger'); + const fullText = node.textContent || ''; + + // Check if there's text before the trigger character (corruption case) + let triggerCharIndex = -1; + let triggerChar = ''; + + if (menus) { + for (const menu of menus) { + const index = fullText.indexOf(menu.trigger); + if (index >= 0 && (triggerCharIndex === -1 || index < triggerCharIndex)) { + triggerCharIndex = index; + triggerChar = menu.trigger; + } + } + } + + if (triggerCharIndex > 0) { + // Text before trigger - extract it as separate text token + const textBefore = fullText.substring(0, triggerCharIndex); + tokens.push({ type: 'text', value: textBefore }); + } + + if (triggerCharIndex >= 0) { + // Extract trigger + const value = fullText.substring(triggerCharIndex + 1); + + // Check if the value contains ANY trigger character (nested trigger) + // Find the earliest trigger character in the value + let nestedTriggerIndex = -1; + let nestedTriggerChar = ''; + + if (menus) { + for (const menu of menus) { + const index = value.indexOf(menu.trigger); + if (index > 0 && (nestedTriggerIndex === -1 || index < nestedTriggerIndex)) { + nestedTriggerIndex = index; + nestedTriggerChar = menu.trigger; + } + } + } + + if (nestedTriggerIndex > 0) { + // Split: first trigger + space + second trigger + const firstValue = value.substring(0, nestedTriggerIndex).trim(); + const afterFirst = value.substring(nestedTriggerIndex); + + // First trigger + tokens.push({ + type: 'trigger', + value: firstValue, + triggerChar, + id, + }); + + // Space before second trigger + const spaceBefore = value.substring(firstValue.length, nestedTriggerIndex); + if (spaceBefore) { + tokens.push({ type: 'text', value: spaceBefore }); + } + + // Second trigger (without the trigger char) + const secondValue = afterFirst.substring(1); + tokens.push({ + type: 'trigger', + value: secondValue, + triggerChar: nestedTriggerChar, + id: generateTokenId('trigger'), + }); + } else { + // Normal trigger, no nesting + tokens.push({ + type: 'trigger', + value, + triggerChar, + id, + }); + } + } else { + // No trigger character found - treat entire content as text + if (fullText) { + tokens.push({ type: 'text', value: fullText }); + } + } + } else if (tokenType === ELEMENT_TYPES.REFERENCE || tokenType === ELEMENT_TYPES.PINNED) { + flushText(); + + const cursorSpotBefore = findElement(node, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }); + if (cursorSpotBefore) { + const beforeText = (cursorSpotBefore.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (beforeText) { + tokens.push({ type: 'text', value: beforeText }); + } + } + + // Extract label from token's text content (excluding cursor spots) + let label = ''; + for (const child of Array.from(node.childNodes)) { + if (isTextNode(child)) { + label += child.textContent || ''; + } else if (isHTMLElement(child)) { + const childType = getTokenType(child); + if (childType !== ELEMENT_TYPES.CURSOR_SPOT_BEFORE && childType !== ELEMENT_TYPES.CURSOR_SPOT_AFTER) { + label += child.textContent || ''; + } + } + } + label = label.replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), '').trim(); + + const instanceId = node.getAttribute('data-id') || ''; + const menuId = node.getAttribute('data-menu-id') || ''; + + // Look up option from menu definition using the label + let value = ''; + if (menuId && menus && label) { + const menu = menus.find(m => m.id === menuId); + if (menu) { + const option = findOptionInMenu(menu.options, label); + if (option) { + value = option.value || ''; + label = option.label || option.value || label; + } + } + } + + const token: PromptInputProps.ReferenceToken = { + type: 'reference', + id: instanceId, + value, + label, + menuId, + }; + if (tokenType === ELEMENT_TYPES.PINNED) { + token.pinned = true; + } + + // Only add reference token if it has a label (skip empty/corrupted tokens) + if (label) { + tokens.push(token); + } + + const cursorSpotAfter = findElement(node, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_AFTER }); + if (cursorSpotAfter) { + const afterText = (cursorSpotAfter.textContent || '').replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); + if (afterText) { + tokens.push({ type: 'text', value: afterText }); + } + } + } else { + Array.from(node.childNodes).forEach(processNode); + } + } + }; + + Array.from(p.childNodes).forEach(processNode); + flushText(); + + return tokens; +} + +export function getPromptText(tokens: readonly PromptInputProps.InputToken[]): string { + return tokens + .map(token => { + if (isTriggerToken(token)) { + return token.triggerChar + token.value; + } + return token.value; + }) + .join(''); +} + +export function findLastPinnedTokenIndex(tokens: readonly PromptInputProps.InputToken[]): number { + for (let i = tokens.length - 1; i >= 0; i--) { + if (isPinnedReferenceToken(tokens[i])) { + return i; + } + } + return -1; +} + +// ============================================================================ +// TRIGGER DETECTION (text-based) +// ============================================================================ + +export { detectTriggersInText } from './token-utils'; + +export function detectTriggersInTokens( + tokens: readonly PromptInputProps.InputToken[], + menus: readonly PromptInputProps.MenuDefinition[] +): PromptInputProps.InputToken[] { + const result: PromptInputProps.InputToken[] = []; + + for (const token of tokens) { + if (isTextToken(token)) { + const detectedTokens = detectTriggersInText(token.value, menus, result); + result.push(...detectedTokens); + } else { + result.push(token); + } + } + + return result; +} + +// ============================================================================ +// MENU SELECTION +// ============================================================================ + +export function handleMenuSelection( + tokens: readonly PromptInputProps.InputToken[], + selectedOption: { + value: string; + label?: string; + }, + menuId: string, + isPinned: boolean, + activeTrigger: PromptInputProps.TriggerToken +): MenuSelectionResult { + const newTokens = [...tokens]; + const triggerIndex = newTokens.findIndex(t => isTriggerToken(t) && t.id === activeTrigger.id); + + if (isPinned) { + const pinnedToken: PromptInputProps.ReferenceToken = { + type: 'reference', + id: generateTokenId('ref'), + label: selectedOption.label || selectedOption.value || '', + value: selectedOption.value || '', + menuId, + pinned: true, + }; + + newTokens.splice(triggerIndex, 1); + + let insertIndex = 0; + while (insertIndex < newTokens.length && isPinnedReferenceToken(newTokens[insertIndex])) { + insertIndex++; + } + + newTokens.splice(insertIndex, 0, pinnedToken); + const cursorPos = getCursorPositionAtIndex(newTokens, insertIndex); + return { tokens: newTokens, cursorPosition: cursorPos, insertedToken: pinnedToken }; + } else { + const referenceToken: PromptInputProps.ReferenceToken = { + type: 'reference', + id: generateTokenId('ref'), + label: selectedOption.label || selectedOption.value || '', + value: selectedOption.value || '', + menuId, + }; + + newTokens.splice(triggerIndex, 1, referenceToken); + + let cursorPos = 0; + for (const token of newTokens) { + cursorPos += getTokenCursorLength(token); + if (isReferenceToken(token) && token.id === selectedOption.value) { + break; + } + } + + return { tokens: newTokens, cursorPosition: cursorPos, insertedToken: referenceToken }; + } +} + +// ============================================================================ +// TOKEN PROCESSING +// ============================================================================ + +export function processTokens( + tokens: readonly PromptInputProps.InputToken[], + config: ShortcutsConfig, + options: { + source: UpdateSource; + detectTriggers?: boolean; + } +): PromptInputProps.InputToken[] { + let result = [...tokens]; + + if (options.detectTriggers && config.menus) { + result = detectTriggersInTokens(result, config.menus); + } + + return result; +} diff --git a/src/prompt-input/core/token-renderer.tsx b/src/prompt-input/core/token-renderer.tsx index ca92d876ee..b8fa4a173b 100644 --- a/src/prompt-input/core/token-renderer.tsx +++ b/src/prompt-input/core/token-renderer.tsx @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot, Root } from 'react-dom/client'; import Token from '../../token/internal'; import { PromptInputProps } from '../interfaces'; import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; -import { isBreakToken, isBRElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; import { createParagraph, createTrailingBreak, @@ -17,35 +16,32 @@ import { generateTokenId, getTokenType, insertAfter, -} from './utils'; +} from './dom-utils'; +import { isBreakToken, isBRElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; import styles from '../styles.css.js'; // REACT COMPONENT MANAGEMENT -const rootsMap = new Map(); +const rootsMap = new Map(); function renderComponent(element: React.ReactElement, container: HTMLElement): void { - if ('createRoot' in ReactDOM) { - const ReactDOMClient = ReactDOM as any; - let root = rootsMap.get(container); - if (!root) { - root = ReactDOMClient.createRoot(container); - rootsMap.set(container, root); - } - root.render(element); - } else { - ReactDOM.render(element, container); + let root = rootsMap.get(container); + if (!root) { + root = createRoot(container); + rootsMap.set(container, root); } + + queueMicrotask(() => { + root!.render(element); + }); } export function unmountComponent(container: HTMLElement): void { const root = rootsMap.get(container); - if (root && 'unmount' in root) { + if (root) { root.unmount(); rootsMap.delete(container); - } else { - ReactDOM.unmountComponentAtNode(container); } } @@ -270,8 +266,6 @@ export function renderTokensToDOM( const instanceId = container.getAttribute('data-id'); if (instanceId && container.isConnected) { existingContainers.set(instanceId, container); - } else if (container.isConnected) { - unmountComponent(container); } }); reactContainers.clear(); @@ -321,11 +315,13 @@ export function renderTokensToDOM( // Reuse existing trigger element and update its content span = existingTriggers.get(token.id)!; span.textContent = token.triggerChar + token.value; + span.className = styles['trigger-token']; existingTriggers.delete(token.id); } else { // Create new trigger element span = document.createElement('span'); span.setAttribute('data-type', ELEMENT_TYPES.TRIGGER); + span.className = styles['trigger-token']; if (token.id) { span.setAttribute('data-id', token.id); } @@ -379,7 +375,16 @@ export function renderTokensToDOM( continue; } - if (existingNode) { + // Check if existingNode was moved (is now in newNodes at a different position) + if (existingNode && newNodes.includes(existingNode)) { + // Don't replace - the existing node was moved elsewhere + // Just append the new node + if (i < p.childNodes.length) { + p.insertBefore(newNode, p.childNodes[i]); + } else { + p.appendChild(newNode); + } + } else if (existingNode) { // Replace existing node with new node p.replaceChild(newNode, existingNode); } else { @@ -393,15 +398,7 @@ export function renderTokensToDOM( targetElement.removeChild(targetElement.lastChild!); } - existingContainers.forEach(container => { - if (container.isConnected) { - unmountComponent(container); - } - }); - normalizeParagraphsAfterRender(targetElement); - // Cursor restoration is handled by the unified system in use-editable-tokens - return { newTriggerElement, lastReferenceWithZwnj }; } diff --git a/src/prompt-input/core/token-utils.ts b/src/prompt-input/core/token-utils.ts new file mode 100644 index 0000000000..5522a9e974 --- /dev/null +++ b/src/prompt-input/core/token-utils.ts @@ -0,0 +1,342 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { PromptInputProps } from '../interfaces'; +import { EditableState } from '../tokens/use-editable-tokens'; +import { ELEMENT_TYPES } from './constants'; +import { getTokenCursorLength, setCursorPosition } from './cursor-manager'; +import { applySafariCursorFix, setCursorOverride } from './cursor-utils'; +import { findAllParagraphs, generateTokenId, getTokenType } from './dom-utils'; +import { getPromptText } from './token-operations'; +import { isBreakToken, isHTMLElement, isPinnedReferenceToken, isTextNode } from './type-guards'; + +function findLastPinnedTokenIndex(tokens: readonly PromptInputProps.InputToken[]): number { + for (let i = tokens.length - 1; i >= 0; i--) { + if (isPinnedReferenceToken(tokens[i])) { + return i; + } + } + return -1; +} + +export function enforcePinnedTokenOrdering( + tokens: readonly PromptInputProps.InputToken[] +): PromptInputProps.InputToken[] { + const lastPinnedIndex = findLastPinnedTokenIndex(tokens); + + if (lastPinnedIndex === -1) { + return [...tokens]; + } + + const pinnedTokens: PromptInputProps.InputToken[] = []; + const forbiddenContent: PromptInputProps.InputToken[] = []; + const allowedContent: PromptInputProps.InputToken[] = []; + + tokens.forEach((token, index) => { + if (isPinnedReferenceToken(token)) { + pinnedTokens.push(token); + } else if (index <= lastPinnedIndex) { + forbiddenContent.push(token); + } else { + allowedContent.push(token); + } + }); + + return [...pinnedTokens, ...forbiddenContent, ...allowedContent]; +} + +export function canDeleteToken(token: PromptInputProps.InputToken): boolean { + return !isPinnedReferenceToken(token); +} + +export function areAllTokensPinned(tokens: readonly PromptInputProps.InputToken[]): boolean { + return tokens.every(isPinnedReferenceToken); +} + +export function validateTriggerWithPinnedTokens( + menu: PromptInputProps.MenuDefinition, + precedingTokens: readonly PromptInputProps.InputToken[] +): boolean { + if (menu.useAtStart) { + return areAllTokensPinned(precedingTokens); + } + return true; +} + +export function validateTrigger( + menu: PromptInputProps.MenuDefinition, + triggerIndex: number, + text: string, + precedingTokens: readonly PromptInputProps.InputToken[] +): boolean { + const isAtStart = triggerIndex === 0; + const charBefore = triggerIndex > 0 ? text[triggerIndex - 1] : ''; + const isAfterWhitespace = /\s/.test(charBefore); + + if (menu.useAtStart) { + return isAtStart && areAllTokensPinned(precedingTokens); + } + + return isAtStart || isAfterWhitespace; +} + +export function detectTriggersInText( + text: string, + menus: readonly PromptInputProps.MenuDefinition[], + precedingTokens: readonly PromptInputProps.InputToken[] +): PromptInputProps.InputToken[] { + const results: PromptInputProps.InputToken[] = []; + let position = 0; + + while (position < text.length) { + let foundTrigger = false; + + for (const menu of menus) { + const triggerIndex = text.indexOf(menu.trigger, position); + if (triggerIndex === -1) { + continue; + } + + if (!validateTrigger(menu, triggerIndex, text, precedingTokens)) { + continue; + } + + const beforeTrigger = text.substring(position, triggerIndex); + if (beforeTrigger) { + results.push({ type: 'text', value: beforeTrigger }); + } + + const afterTrigger = text.substring(triggerIndex + menu.trigger.length); + let filterText = ''; + let remainingText = afterTrigger; + + if (afterTrigger && !/^\s/.test(afterTrigger)) { + let endIndex = 0; + while (endIndex < afterTrigger.length && !/\s/.test(afterTrigger[endIndex])) { + endIndex++; + } + filterText = afterTrigger.substring(0, endIndex); + remainingText = afterTrigger.substring(endIndex); + } + + results.push({ + type: 'trigger', + value: filterText, + triggerChar: menu.trigger, + id: generateTokenId('trigger'), + }); + + if (remainingText) { + results.push({ type: 'text', value: remainingText }); + } + + position = text.length; // Move to end to exit while loop + foundTrigger = true; + break; + } + + if (!foundTrigger) { + const remainingText = text.substring(position); + if (remainingText) { + results.push({ type: 'text', value: remainingText }); + } + break; + } + } + + return results.length > 0 ? results : [{ type: 'text', value: text }]; +} + +export type ArrowDirection = 'left' | 'right'; + +export interface AdjacentTokenResult { + sibling: Node | null; + isReferenceToken: boolean; +} + +export function findAdjacentToken(container: Node, offset: number, direction: ArrowDirection): AdjacentTokenResult { + let sibling: Node | null = null; + + if (isTextNode(container)) { + const isAtBoundary = direction === 'left' ? offset === 0 : offset === (container.textContent?.length || 0); + + if (isAtBoundary) { + sibling = direction === 'left' ? container.previousSibling : container.nextSibling; + } + } else if (isHTMLElement(container)) { + if (direction === 'left') { + sibling = offset > 0 ? container.childNodes[offset - 1] : container.previousSibling; + } else { + sibling = offset < container.childNodes.length ? container.childNodes[offset] : container.nextSibling; + } + } + + const siblingType = isHTMLElement(sibling) ? getTokenType(sibling) : null; + const isReferenceToken = siblingType === ELEMENT_TYPES.REFERENCE || siblingType === ELEMENT_TYPES.PINNED; + + return { sibling, isReferenceToken }; +} + +export type MergeDirection = 'forward' | 'backward'; + +interface MergeParagraphsParams { + direction: MergeDirection; + editableElement: HTMLDivElement; + tokens: readonly PromptInputProps.InputToken[]; + currentParagraphIndex: number; + tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void; + state?: EditableState; +} + +export function mergeParagraphs(params: MergeParagraphsParams): boolean { + const { direction, editableElement, tokens, currentParagraphIndex, tokensToText, onChange, state } = params; + + const paragraphs = findAllParagraphs(editableElement); + + if (direction === 'backward') { + if (currentParagraphIndex <= 0) { + return false; + } + } else { + if (currentParagraphIndex >= paragraphs.length - 1) { + return false; + } + } + + const breakIndexToRemove = direction === 'backward' ? currentParagraphIndex : currentParagraphIndex + 1; + + let breakCount = 0; + let cursorPosition = 0; + + const newTokens = tokens.filter(token => { + if (isBreakToken(token)) { + breakCount++; + if (breakCount === breakIndexToRemove) { + return false; + } + cursorPosition += 1; + } else { + if (breakCount < breakIndexToRemove) { + cursorPosition += getTokenCursorLength(token); + } + } + return true; + }); + + const value = tokensToText ? tokensToText(newTokens) : getPromptText(newTokens); + onChange({ value, tokens: newTokens }); + + if (state) { + setCursorOverride(state, cursorPosition); + state.isDeleteOperation = true; + + // Apply Safari cursor fix immediately for line deletions + applySafariCursorFix(editableElement, state, cursorPosition); + } else { + requestAnimationFrame(() => { + setCursorPosition(editableElement, cursorPosition); + }); + } + + return true; +} + +export function handleBackspaceAtParagraphStart( + event: React.KeyboardEvent, + editableElement: HTMLDivElement, + tokens: readonly PromptInputProps.InputToken[], + tokensToText: ((tokens: readonly PromptInputProps.InputToken[]) => string) | undefined, + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, + state?: EditableState +): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + + if (range.startOffset !== 0 || range.startContainer.nodeName !== 'P') { + return false; + } + + const paragraphs = findAllParagraphs(editableElement); + const currentP = range.startContainer; + const pIndex = Array.from(paragraphs).indexOf(currentP as HTMLParagraphElement); + + if (pIndex < 0) { + return false; + } + + event.preventDefault(); + + return mergeParagraphs({ + direction: 'backward', + editableElement, + tokens, + currentParagraphIndex: pIndex, + tokensToText, + onChange, + state, + }); +} + +export function handleDeleteAtParagraphEnd( + event: React.KeyboardEvent, + editableElement: HTMLDivElement, + tokens: readonly PromptInputProps.InputToken[], + tokensToText: ((tokens: readonly PromptInputProps.InputToken[]) => string) | undefined, + cursorPosition: number, + onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, + state?: EditableState +): boolean { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return false; + } + + const range = selection.getRangeAt(0); + const container = range.startContainer; + + let isAtEndOfParagraph = false; + let currentP: HTMLParagraphElement | null = null; + + if (container.nodeName === 'P') { + currentP = container as HTMLParagraphElement; + const hasOnlyTrailingBR = currentP.childNodes.length === 1 && currentP.firstChild?.nodeName === 'BR'; + isAtEndOfParagraph = hasOnlyTrailingBR || range.startOffset === currentP.childNodes.length; + } else if (container.nodeType === Node.TEXT_NODE) { + isAtEndOfParagraph = range.startOffset === (container.textContent?.length || 0) && !container.nextSibling; + let node: Node | null = container; + while (node && node.nodeName !== 'P') { + node = node.parentNode; + } + currentP = node as HTMLParagraphElement; + } + + if (!isAtEndOfParagraph || !currentP) { + return false; + } + + const paragraphs = findAllParagraphs(editableElement); + const pIndex = Array.from(paragraphs).indexOf(currentP); + + if (pIndex < 0) { + return false; + } + + event.preventDefault(); + + return mergeParagraphs({ + direction: 'forward', + editableElement, + tokens, + currentParagraphIndex: pIndex, + tokensToText, + onChange, + state, + }); +} diff --git a/src/prompt-input/core/utils.ts b/src/prompt-input/core/utils.ts deleted file mode 100644 index b21ca20ab8..0000000000 --- a/src/prompt-input/core/utils.ts +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { ELEMENT_TYPES } from './constants'; - -import styles from '../styles.css.js'; - -// TOKEN TYPE UTILITIES - -/** - * Gets the token type from an element's data-type attribute. - * @param element The element to check - * @returns The token type string, or null if not set - */ -export function getTokenType(element: HTMLElement): string | null { - return element.getAttribute('data-type'); -} - -/** - * Inserts a node after a reference node - */ -export function insertAfter(newNode: Node, referenceNode: Node): void { - const parent = referenceNode.parentNode; - if (!parent) { - return; - } - - if (referenceNode.nextSibling) { - parent.insertBefore(newNode, referenceNode.nextSibling); - } else { - parent.appendChild(newNode); - } -} - -// DOM CREATION - -export function createParagraph(): HTMLParagraphElement { - const p = document.createElement('p'); - p.className = styles.paragraph || 'paragraph'; - p.setAttribute('data-paragraph-id', generateTokenId('p')); - return p; -} - -export function createTrailingBreak(): HTMLBRElement { - const br = document.createElement('br'); - br.setAttribute('data-id', ELEMENT_TYPES.TRAILING_BREAK); - return br; -} - -// DOM STATE MANAGEMENT - -export function ensureEmptyState(element: HTMLElement): void { - element.innerHTML = ''; - const p = createParagraph(); - p.appendChild(createTrailingBreak()); - element.appendChild(p); -} - -export function isElementEffectivelyEmpty(element: HTMLElement): boolean { - if (element.childNodes.length === 0) { - return true; - } - - for (const child of Array.from(element.childNodes)) { - if (child.nodeType === Node.TEXT_NODE) { - if (child.textContent && child.textContent.trim() !== '') { - return false; - } - } else { - return false; - } - } - return true; -} - -// SELECTION UTILITIES - -export function getCurrentSelection(): Selection | null { - return window.getSelection(); -} - -export function getFirstRange(): Range | null { - const selection = getCurrentSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - return selection.getRangeAt(0); -} - -export function selectAllContent(element: HTMLElement): void { - const selection = getCurrentSelection(); - if (!selection) { - return; - } - - const range = document.createRange(); - range.selectNodeContents(element); - - selection.removeAllRanges(); - selection.addRange(range); -} - -// ID GENERATION - -/** - * Generates a unique ID for tokens (triggers, references, etc.). - * @param prefix The prefix for the ID (e.g., 'trigger', 'reference', 'p') - * @returns A unique ID based on timestamp - */ -export function generateTokenId(prefix: string): string { - return `${prefix}-${Date.now()}`; -} - -// DOM QUERY UTILITIES - -interface TokenQueryOptions { - tokenType?: string | string[]; - tokenId?: string; -} - -/** - * Build a CSS selector from query options - * @param options Query options (tokenType, tokenId) - * @returns CSS selector string, or empty string if no options provided - */ -function buildTokenSelector(options: TokenQueryOptions): string { - const { tokenType, tokenId } = options; - - let selector = ''; - - if (tokenType) { - const types = Array.isArray(tokenType) ? tokenType : [tokenType]; - selector = types.map(type => `[data-type="${type}"]`).join(', '); - } - - if (tokenId) { - selector += `[data-id="${tokenId}"]`; - } - - return selector; -} - -/** - * Find all elements matching the query options - * @param container The container element to search within - * @param options Query options (tokenType, tokenId) - * @returns Array of matching elements - * - * @example - * // Find all triggers - * findElements(container, { tokenType: ELEMENT_TYPES.TRIGGER }) - * - * // Find all cursor spots (before and after) - * findElements(container, { tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER] }) - * - * // Find reference wrappers by token ID - * findElements(container, { tokenType: ELEMENT_TYPES.REFERENCE, tokenId: 'ref-123' }) - * - * // Find trigger by ID - * findElements(container, { tokenType: ELEMENT_TYPES.TRIGGER, tokenId: 'trigger-123' }) - */ -export function findElements(container: HTMLElement, options: TokenQueryOptions): HTMLElement[] { - const selector = buildTokenSelector(options); - return selector ? Array.from(container.querySelectorAll(selector)) : []; -} - -/** - * Find first element matching the query options - * @param container The container element to search within - * @param options Query options (tokenType, tokenId) - * @returns The first matching element, or null if not found - * - * @example - * // Find first trigger - * findElement(container, { tokenType: ELEMENT_TYPES.TRIGGER }) - * - * // Find reference or pinned token in wrapper - * findElement(wrapper, { tokenType: [ELEMENT_TYPES.REFERENCE, ELEMENT_TYPES.PINNED] }) - * - * // Find cursor spot before - * findElement(wrapper, { tokenType: ELEMENT_TYPES.CURSOR_SPOT_BEFORE }) - */ -export function findElement(container: HTMLElement, options: TokenQueryOptions): HTMLElement | null { - const selector = buildTokenSelector(options); - return selector ? container.querySelector(selector) : null; -} - -/** - * Find all paragraph elements in the container - * @param container The container element to search within - * @returns Array of all paragraph elements - */ -export function findAllParagraphs(container: HTMLElement): HTMLParagraphElement[] { - return Array.from(container.querySelectorAll('p')); -} diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index 6fbdb44843..01aaee9e4a 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -61,6 +61,8 @@ export interface PromptInputProps * - Trigger tokens: `value` contains the filter text, `triggerChar` for the trigger character * * When `menus` is defined, you should use `tokens` to control the content instead of `value`. + * + * Requires React 18. */ tokens?: readonly PromptInputProps.InputToken[]; @@ -76,6 +78,8 @@ export interface PromptInputProps * Use this to customize serialization, for example: * - Using `label` instead of `value` for reference tokens * - Adding custom formatting or separators between tokens + * + * Requires React 18. */ tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; @@ -191,17 +195,23 @@ export interface PromptInputProps /** * Menus that can be triggered via specific symbols (e.g., "/" or "@"). * For menus only relevant to triggers at the start of the input, set `useAtStart: true`, defaults to `false`. + * + * Requires React 18. */ menus?: PromptInputProps.MenuDefinition[]; /** * Maximum height of the menu dropdown in pixels. * When not specified, the menu will grow to fit its content. + * + * Requires React 18. */ maxMenuHeight?: number; /** * Called whenever a user selects an option in a menu. + * + * Requires React 18. */ onMenuItemSelect?: NonCancelableEventHandler; @@ -219,6 +229,8 @@ export interface PromptInputProps * - `filteringText` - The value to use to fetch options (undefined for pagination). * - `firstPage` - Indicates that you should fetch the first page of options. * - `samePage` - Indicates that you should fetch the same page (for example, when clicking recovery button). + * + * Requires React 18. */ onMenuLoadItems?: NonCancelableEventHandler; @@ -229,6 +241,8 @@ export interface PromptInputProps * The detail object contains: * - `menuId` - The ID of the menu that triggered the event. * - `filteringText` - The text to use for filtering options. + * + * Requires React 18. */ onMenuFilter?: NonCancelableEventHandler; diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index be82557bee..a2cbec59d9 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -1,7 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React, { Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import ReactDOM from 'react-dom'; import clsx from 'clsx'; import { useDensityMode, useStableCallback, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; @@ -21,29 +20,24 @@ import TextareaMode from './components/textarea-mode'; import TokenMode from './components/token-mode'; import { CURSOR_DETECTION_DELAY, DEFAULT_MAX_ROWS, NEXT_TICK_TIMEOUT } from './core/constants'; import { isCursorInTriggerToken, setCursorPosition, setCursorRange } from './core/cursor-manager'; +import { normalizeSelection, selectAllContent } from './core/cursor-utils'; import { createCursorNormalizationHandler, createKeyboardHandlers, - createSelectionNormalizationHandler, - handleSpaceAfterClosedTrigger, -} from './core/event-handlers'; -import { createEditableState } from './core/event-handlers'; -import { handleArrowKeyNavigation, - handleBackspaceAtParagraphStart, - handleDeleteAtParagraphEnd, handleReferenceTokenDeletion, + handleSpaceAfterClosedTrigger, splitParagraphAtCursor, } from './core/event-handlers'; import { MenuItem, useMenuItems } from './core/menu-state'; import { useMenuLoadMore } from './core/menu-state'; -import { handleMenuSelection } from './core/token-engine'; -import { getPromptText } from './core/token-extractor'; -import { selectAllContent } from './core/utils'; +import { handleMenuSelection } from './core/token-operations'; +import { getPromptText } from './core/token-operations'; +import { handleBackspaceAtParagraphStart, handleDeleteAtParagraphEnd } from './core/token-utils'; import { PromptInputProps } from './interfaces'; import { useShortcuts } from './shortcuts/use-shortcuts'; import { getPromptInputStyles } from './styles'; -import { useEditableTokens } from './tokens/use-editable-tokens'; +import { createEditableState, useEditableTokens } from './tokens/use-editable-tokens'; import { insertTextIntoContentEditable } from './utils/insert-text-content-editable'; import styles from './styles.css.js'; @@ -196,22 +190,22 @@ const InternalPromptInput = React.forwardRef( } if (isTokenMode) { - if (!editableElementRef.current || !tokens || !menus) { + if (!editableElementRef.current || !tokens) { return; } - insertTextIntoContentEditable( - editableElementRef.current, - text, - cursorStart, - cursorEnd, - tokens, - menus, - detail => fireNonCancelableEvent(onChange, detail), - tokensToText ?? getPromptText, - lastKnownCursorPositionRef.current, - lastKnownCursorPositionRef + // Calculate offset for pinned references + // Pinned references are always at the start and can't have content inserted before/between them + const pinnedTokens = tokens.filter( + (token): token is PromptInputProps.ReferenceToken => token.type === 'reference' && token.pinned === true ); + const pinnedOffset = pinnedTokens.length; + + // Adjust cursor positions to account for pinned tokens + const adjustedCursorStart = cursorStart !== undefined ? cursorStart + pinnedOffset : undefined; + const adjustedCursorEnd = cursorEnd !== undefined ? cursorEnd + pinnedOffset : undefined; + + insertTextIntoContentEditable(editableElementRef.current, text, adjustedCursorStart, adjustedCursorEnd); } else { // Textarea mode if (!textareaRef.current) { @@ -238,7 +232,8 @@ const InternalPromptInput = React.forwardRef( } }, }), - [getActiveElement, isTokenMode, disabled, readOnly, tokens, menus, onChange, tokensToText] + // eslint-disable-next-line react-hooks/exhaustive-deps + [getActiveElement, isTokenMode, disabled, readOnly] ); /** @@ -323,8 +318,32 @@ const InternalPromptInput = React.forwardRef( editableState ); - document.addEventListener('selectionchange', normalizeCursorPosition); - return () => document.removeEventListener('selectionchange', normalizeCursorPosition); + // Track mouse state to skip normalization during/after mouse clicks + const handleMouseDown = () => { + window.isMouseDownForCursor = true; + }; + const handleMouseUp = () => { + // Delay clearing the flag to allow the click to complete + setTimeout(() => { + window.isMouseDownForCursor = false; + }, 100); + }; + + const normalizeIfNotMouse = () => { + if (!window.isMouseDownForCursor) { + normalizeCursorPosition(); + } + }; + + document.addEventListener('selectionchange', normalizeIfNotMouse); + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('selectionchange', normalizeIfNotMouse); + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mouseup', handleMouseUp); + }; }, [isTokenMode, editableState]); // Normalize selection to include entire reference tokens when boundary is in cursor spots @@ -333,10 +352,24 @@ const InternalPromptInput = React.forwardRef( return; } - const normalizeSelection = createSelectionNormalizationHandler(); + const handleSelectionChange = () => normalizeSelection(window.getSelection()); + const handleMouseDown = () => { + window.isMouseDown = true; + }; + const handleMouseUp = () => { + window.isMouseDown = false; + normalizeSelection(window.getSelection()); + }; + + document.addEventListener('selectionchange', handleSelectionChange); + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mouseup', handleMouseUp); - document.addEventListener('selectionchange', normalizeSelection); - return () => document.removeEventListener('selectionchange', normalizeSelection); + return () => { + document.removeEventListener('selectionchange', handleSelectionChange); + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mouseup', handleMouseUp); + }; }, [isTokenMode]); const handleTextareaKeyDown = (event: React.KeyboardEvent) => { @@ -402,18 +435,22 @@ const InternalPromptInput = React.forwardRef( } if (event.key === 'Backspace' && tokens && editableElementRef.current) { + // Prevent backspace in completely empty input + if (tokens.length === 0) { + event.preventDefault(); + return; + } + if ( handleBackspaceAtParagraphStart( event, editableElementRef.current, tokens, tokensToText, - getPromptText, (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => { markTokensAsSent(detail.tokens); fireNonCancelableEvent(onChange, detail); }, - setCursorPosition, editableState ) ) { @@ -428,13 +465,11 @@ const InternalPromptInput = React.forwardRef( editableElementRef.current, tokens, tokensToText, - getPromptText, lastKnownCursorPositionRef.current, (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => { markTokensAsSent(detail.tokens); fireNonCancelableEvent(onChange, detail); }, - setCursorPosition, editableState ) ) { @@ -454,7 +489,8 @@ const InternalPromptInput = React.forwardRef( editableElementRef.current, shortcuts.menuIsOpen, shortcuts.triggerValueWhenClosed, - editableState + editableState, + menus ) ) { return; @@ -497,7 +533,6 @@ const InternalPromptInput = React.forwardRef( // Cleanup on unmount return () => { window.removeEventListener('resize', handleResize); - containers.forEach(container => ReactDOM.unmountComponentAtNode(container)); containers.clear(); }; }, [adjustInputHeight]); @@ -587,7 +622,6 @@ const InternalPromptInput = React.forwardRef( onAction: onAction ? detail => fireNonCancelableEvent(onAction, detail) : undefined, tokensToText, tokens, - getPromptText, closeMenu: () => { ignoreCursorDetection.current = true; shortcuts.setCursorInTrigger(false); @@ -601,8 +635,10 @@ const InternalPromptInput = React.forwardRef( setTimeout(() => setTokenOperationAnnouncement(''), 100); }, i18nStrings, + disabled, + readOnly, }); - }, [onAction, tokensToText, tokens, ignoreCursorDetection, shortcuts, i18nStrings]); + }, [onAction, tokensToText, tokens, ignoreCursorDetection, shortcuts, i18nStrings, disabled, readOnly]); // Menu load more controller const menuLoadMoreResult = useMenuLoadMore({ diff --git a/src/prompt-input/shortcuts/use-shortcuts.ts b/src/prompt-input/shortcuts/use-shortcuts.ts index b5bdf99a8f..4705e3820e 100644 --- a/src/prompt-input/shortcuts/use-shortcuts.ts +++ b/src/prompt-input/shortcuts/use-shortcuts.ts @@ -8,10 +8,11 @@ import { useStableCallback } from '@cloudscape-design/component-toolkit/internal import { getFirstScrollableParent } from '../../internal/utils/scrollable-containers'; import { ELEMENT_TYPES } from '../core/constants'; -import { processTokens, type UpdateSource } from '../core/token-engine'; -import { getPromptText } from '../core/token-extractor'; +import { getCurrentSelection, getFirstRange } from '../core/cursor-utils'; +import { findElement } from '../core/dom-utils'; +import { processTokens, type UpdateSource } from '../core/token-operations'; +import { getPromptText } from '../core/token-operations'; import { isHTMLElement, isTextNode, isTriggerToken } from '../core/type-guards'; -import { findElement, getCurrentSelection, getFirstRange } from '../core/utils'; import type { PromptInputProps } from '../interfaces'; // ============================================================================ diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss index cca7c09fe1..1806d4e31f 100644 --- a/src/prompt-input/styles.scss +++ b/src/prompt-input/styles.scss @@ -325,6 +325,11 @@ $invalid-border-offset: constants.$invalid-control-left-padding; padding-inline: awsui.$space-xxxs; } +.trigger-token { + font-style: italic; + text-decoration: underline dashed; +} + // Paragraph elements - reset browser default margins/padding .paragraph { @include styles.styles-reset; diff --git a/src/prompt-input/tokens/use-editable-tokens.ts b/src/prompt-input/tokens/use-editable-tokens.ts index f40b1dd079..f21f5ed3e6 100644 --- a/src/prompt-input/tokens/use-editable-tokens.ts +++ b/src/prompt-input/tokens/use-editable-tokens.ts @@ -3,22 +3,64 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; -import { ELEMENT_TYPES, SPECIAL_CHARS } from '../core/constants'; -import { getCursorPosition, getTokenCursorLength, setCursorPosition } from '../core/cursor-manager'; -import { type EditableState } from '../core/event-handlers'; -import { extractTokensFromDOM, getPromptText, moveForbiddenTextAfterPinnedTokens } from '../core/token-extractor'; +import { ELEMENT_TYPES } from '../core/constants'; +import { getCursorPosition, getCursorPositionAtIndex, setCursorPosition } from '../core/cursor-manager'; +import { + applySafariCursorFix, + calculateEndPosition, + extractTextFromCursorSpots, + positionCursorAfterMovedText, +} from '../core/cursor-utils'; +import { + createParagraph, + ensureValidEmptyState, + findAllParagraphs, + findElements, + isEmptyState, +} from '../core/dom-utils'; +import { extractTokensFromDOM, getPromptText } from '../core/token-operations'; import { renderTokensToDOM } from '../core/token-renderer'; +import { enforcePinnedTokenOrdering } from '../core/token-utils'; import { isBreakToken, isBRElement, + isPinnedReferenceToken, isReferenceToken, isTextNode, isTextToken, isTriggerToken, } from '../core/type-guards'; -import { createParagraph, ensureEmptyState, findAllParagraphs, findElements, insertAfter } from '../core/utils'; import { PromptInputProps } from '../interfaces'; +interface CursorPositionOverride { + cursorPosition: number; + paragraphId: string | null; +} + +export interface EditableState { + skipNextZwnjUpdate: boolean; + skipNormalization: boolean; + skipCursorRestore: boolean; + targetParagraphId: string | null; + cursorPositionOverride: CursorPositionOverride | null; + menuSelectionTokenId: string | null; + menuSelectionIsPinned: boolean; + isDeleteOperation: boolean; +} + +export function createEditableState(): EditableState { + return { + skipNextZwnjUpdate: false, + skipNormalization: false, + skipCursorRestore: false, + targetParagraphId: null, + cursorPositionOverride: null, + menuSelectionTokenId: null, + menuSelectionIsPinned: false, + isDeleteOperation: false, + }; +} + function shouldRerender( oldTokens: readonly PromptInputProps.InputToken[] | undefined, newTokens: readonly PromptInputProps.InputToken[] | undefined @@ -89,13 +131,25 @@ export function useEditableTokens({ const lastReadOnlyRef = useRef(readOnly); const skipNextZwnjUpdateRef = useRef(false); const skipCursorRestoreRef = useRef(false); + const lastInputTimeRef = useRef(0); + const isTypingIntoEmptyLineRef = useRef(false); const handleInput = useCallback(() => { + lastInputTimeRef.current = Date.now(); + if (!elementRef.current) { return; } - // Capture cursor position BEFORE any DOM manipulation + // Remove trailing BRs FIRST, before capturing cursor + const allParagraphs = findAllParagraphs(elementRef.current); + allParagraphs.forEach(p => { + if (p.childNodes.length > 1 && isBRElement(p.firstChild, ELEMENT_TYPES.TRAILING_BREAK)) { + p.firstChild.remove(); + } + }); + + // Capture cursor position AFTER BR removal const cursorPos = getCursorPosition(elementRef.current); lastKnownCursorPositionRef.current = cursorPos; @@ -111,33 +165,18 @@ export function useEditableTokens({ } if (elementRef.current.children.length === 0) { - ensureEmptyState(elementRef.current); + ensureValidEmptyState(elementRef.current); } const paragraphs = findAllParagraphs(elementRef.current); - paragraphs.forEach(p => { - const cursorSpots = findElements(p, { - tokenType: [ELEMENT_TYPES.CURSOR_SPOT_BEFORE, ELEMENT_TYPES.CURSOR_SPOT_AFTER], - }); - cursorSpots.forEach(spot => { - const content = spot.textContent || ''; - const cleanContent = content.replace(new RegExp(SPECIAL_CHARS.ZWNJ, 'g'), ''); - - if (cleanContent) { - const textNode = document.createTextNode(cleanContent); - const wrapper = spot.parentElement; - if (wrapper) { - if (spot.getAttribute('data-type') === ELEMENT_TYPES.CURSOR_SPOT_BEFORE) { - wrapper.parentNode?.insertBefore(textNode, wrapper); - } else { - insertAfter(textNode, wrapper); - } - } - } - spot.textContent = SPECIAL_CHARS.ZWNJ; - }); - }); + // Extract text from cursor spots and track moved text node + const { movedTextNode } = extractTextFromCursorSpots(paragraphs, true); + + // If cursor was in a spot, position it at the end of the moved text + if (movedTextNode) { + positionCursorAfterMovedText(movedTextNode, elementRef.current, lastKnownCursorPositionRef); + } const directTextNodes = Array.from(elementRef.current.childNodes).filter( node => isTextNode(node) && node.textContent?.trim() @@ -160,30 +199,62 @@ export function useEditableTokens({ // Extract tokens let extractedTokens = extractTokensFromDOM(elementRef.current, menus); - // If all content was deleted or only breaks remain, ensure proper empty state - const onlyBreaks = extractedTokens.every(isBreakToken); + // If a new trigger was just created, render immediately to create the trigger element + // This minimizes the window where cursor is at wrong position + const newTriggers = extractedTokens.filter(isTriggerToken); + const oldTriggers = lastEmittedTokensRef.current?.filter(isTriggerToken) || []; + + if (newTriggers.length > oldTriggers.length) { + // New trigger detected - render immediately to create trigger element + renderTokensToDOM(extractedTokens, elementRef.current, reactContainersRef.current, { disabled, readOnly }); + + // Find the new trigger (not in oldTriggers) + const oldTriggerIds = new Set(oldTriggers.map(t => (isTriggerToken(t) ? t.id : undefined))); + const newTrigger = newTriggers.find(t => isTriggerToken(t) && !oldTriggerIds.has(t.id)); + + // Position cursor inside the new trigger element + if (newTrigger && isTriggerToken(newTrigger) && newTrigger.id) { + const triggerElements = findElements(elementRef.current, { + tokenType: ELEMENT_TYPES.TRIGGER, + tokenId: newTrigger.id, + }); + if (triggerElements.length > 0) { + const triggerElement = triggerElements[0]; + const triggerTextNode = triggerElement.firstChild; + if (triggerTextNode && isTextNode(triggerTextNode)) { + const range = document.createRange(); + range.setStart(triggerTextNode, triggerTextNode.textContent?.length || 0); + range.collapse(true); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + } + } + } - if (extractedTokens.length === 0 || onlyBreaks) { + // If all content was deleted, ensure proper empty state + // Note: break tokens are valid content (newlines), don't clear them + if (extractedTokens.length === 0) { // Ensure we have exactly one paragraph with BR - const paragraphs = findAllParagraphs(elementRef.current); - const hasValidEmptyState = - paragraphs.length === 1 && isBRElement(paragraphs[0].firstChild, ELEMENT_TYPES.TRAILING_BREAK); - if (!hasValidEmptyState) { - ensureEmptyState(elementRef.current); + if (!isEmptyState(elementRef.current)) { + ensureValidEmptyState(elementRef.current); // Cursor will be restored by unified restoration to position 0 lastKnownCursorPositionRef.current = 0; } extractedTokens = []; } - const movedTokens = moveForbiddenTextAfterPinnedTokens(extractedTokens); + const movedTokens = enforcePinnedTokenOrdering(extractedTokens); const tokensWereMoved = movedTokens.some((t, i) => t !== extractedTokens[i]); if (tokensWereMoved) { extractedTokens = movedTokens; // When tokens are moved, position cursor after all content - const position = movedTokens.reduce((sum, token) => sum + getTokenCursorLength(token), 0); + const position = calculateEndPosition(movedTokens); lastKnownCursorPositionRef.current = position; // Render immediately to avoid showing intermediate state @@ -275,18 +346,91 @@ export function useEditableTokens({ skipCursorRestoreRef.current = false; let savedCursorPosition = 0; + let hasCursorOverride = false; + if (shouldRestoreCursor) { - // Check if we have a deletion context with a pre-calculated position - if (editableState.deletionContext) { - savedCursorPosition = editableState.deletionContext.cursorPosition; - editableState.deletionContext = null; + // Check if we have a cursor position override with a pre-calculated position + if (editableState.cursorPositionOverride) { + savedCursorPosition = editableState.cursorPositionOverride.cursorPosition; + hasCursorOverride = true; + editableState.cursorPositionOverride = null; } else { savedCursorPosition = lastKnownCursorPositionRef.current; } } + // Special case: typing into empty line OR typing after a reference + // These cases need immediate cursor restoration to prevent jumping + const prevLastToken = lastRenderedTokensRef.current?.[lastRenderedTokensRef.current.length - 1]; + const justStartedNewLine = prevLastToken && isBreakToken(prevLastToken); + const wasCompletelyEmpty = !lastRenderedTokensRef.current || lastRenderedTokensRef.current.length === 0; + const justAfterReference = prevLastToken && isReferenceToken(prevLastToken); + + // Check if CURRENT LINE (after last break) is only text + let currentLineIsText = false; + if (tokens && tokens.length > 0) { + let lastBreakIndex = -1; + for (let i = tokens.length - 1; i >= 0; i--) { + if (isBreakToken(tokens[i])) { + lastBreakIndex = i; + break; + } + } + const currentLineTokens = tokens.slice(lastBreakIndex + 1); + currentLineIsText = currentLineTokens.length > 0 && currentLineTokens.every(isTextToken); + } + + // Start tracking when typing into empty line OR after reference + if ((justStartedNewLine || wasCompletelyEmpty || justAfterReference) && currentLineIsText) { + isTypingIntoEmptyLineRef.current = true; + } + + // Stop tracking when current line has non-text tokens + if (!currentLineIsText && tokens && tokens.length > 0) { + isTypingIntoEmptyLineRef.current = false; + } + + // Reset when empty + if (!tokens || tokens.length === 0) { + isTypingIntoEmptyLineRef.current = false; + } + + const isTypingIntoEmptyLine = isTypingIntoEmptyLineRef.current; + lastRenderedTokensRef.current = tokens; + if (isTypingIntoEmptyLine) { + const renderResult = renderTokensToDOM(tokens ?? [], elementRef.current, reactContainersRef.current, { + disabled, + readOnly, + }); + + // If a new trigger was just created, position cursor inside it immediately + if (renderResult.newTriggerElement) { + const triggerTextNode = renderResult.newTriggerElement.firstChild; + if (triggerTextNode && isTextNode(triggerTextNode)) { + const range = document.createRange(); + range.setStart(triggerTextNode, triggerTextNode.textContent?.length || 0); + range.collapse(true); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + adjustInputHeight(); + return; + } + } + + // Otherwise restore cursor immediately (synchronously) to prevent jumping + if (document.activeElement === elementRef.current && shouldRestoreCursor) { + setCursorPosition(elementRef.current, savedCursorPosition); + } + + adjustInputHeight(); + return; + } + // Calculate cursor position for space-after-trigger case let cursorPositionToRestore: number | null = null; if (triggerSplitAndMerged && tokens) { @@ -296,7 +440,7 @@ export function useEditableTokens({ const nextToken = tokens[i + 1]; if (isTriggerToken(token) && nextToken && isTextToken(nextToken) && nextToken.value.startsWith(' ')) { - cursorPositionToRestore = tokens.slice(0, i + 1).reduce((sum, t) => sum + getTokenCursorLength(t), 0) + 1; + cursorPositionToRestore = calculateEndPosition(tokens.slice(0, i + 1)) + 1; break; } } @@ -304,8 +448,27 @@ export function useEditableTokens({ renderTokensToDOM(tokens ?? [], elementRef.current, reactContainersRef.current, { disabled, readOnly }); + // Check if we have only pinned references (after submit) + const onlyPinnedReferences = tokens && tokens.length > 0 && tokens.every(isPinnedReferenceToken); + + // Check if this is a special case that needs custom cursor positioning + const needsCalculatedCursorPosition = + editableState.menuSelectionTokenId || + hasCursorOverride || + cursorPositionToRestore !== null || + onlyPinnedReferences; + + // For normal structural changes, restore cursor immediately using lastKnownCursorPositionRef + // This allows insertText and handleInput to control the final cursor position + // For special cases, use RAF restoration with calculated position + if (!needsCalculatedCursorPosition && document.activeElement === elementRef.current) { + setCursorPosition(elementRef.current, lastKnownCursorPositionRef.current); + adjustInputHeight(); + return; + } + // ============================================================================ - // UNIFIED CURSOR RESTORATION + // UNIFIED CURSOR RESTORATION (RAF-based, for special cases) // ============================================================================ // After renderTokensToDOM, always restore cursor position using lastKnownCursorPositionRef // Special cases update the ref before restoration, not position directly @@ -346,9 +509,7 @@ export function useEditableTokens({ const refIndex = tokens.findIndex(t => isReferenceToken(t) && t.id === tokenId); if (refIndex >= 0) { // Calculate position after this reference - targetPosition = tokens - .slice(0, refIndex + 1) - .reduce((sum, token) => sum + getTokenCursorLength(token), 0); + targetPosition = getCursorPositionAtIndex(tokens, refIndex); } } @@ -360,10 +521,19 @@ export function useEditableTokens({ targetPosition = cursorPositionToRestore; } + // Special case 3: Only pinned references (after submit) + // Position cursor after all pinned references + if (onlyPinnedReferences && tokens) { + targetPosition = calculateEndPosition(tokens); + } + // Unified restoration: only restore if element has focus // This prevents stealing focus from other elements if (document.activeElement === elementRef.current) { setCursorPosition(elementRef.current, targetPosition); + + // Apply Safari ghost cursor fix if needed + applySafariCursorFix(elementRef.current, editableState, targetPosition); } }) ); diff --git a/src/prompt-input/utils/insert-text-content-editable.ts b/src/prompt-input/utils/insert-text-content-editable.ts index cec9d7eaea..9ec507b113 100644 --- a/src/prompt-input/utils/insert-text-content-editable.ts +++ b/src/prompt-input/utils/insert-text-content-editable.ts @@ -1,141 +1,45 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { isPinnedReferenceToken, isTextToken, isTriggerToken } from '../core/type-guards'; -import { PromptInputProps } from '../interfaces'; - -function textToTokens(text: string, menus: readonly PromptInputProps.MenuDefinition[]): PromptInputProps.InputToken[] { - return text.split('\n').flatMap((line, i) => { - const tokens: PromptInputProps.InputToken[] = []; - if (i > 0) { - tokens.push({ type: 'break', value: '\n' }); - } - if (!line) { - return tokens; - } - - const firstChar = line.charAt(0); - const matchingMenu = menus.find(m => m.trigger === firstChar); - - tokens.push( - matchingMenu - ? { type: 'trigger', triggerChar: firstChar, value: line.substring(1), id: undefined } - : { type: 'text', value: line } - ); - return tokens; - }); -} - -function getTokenLength(token: PromptInputProps.InputToken): number { - if (isTextToken(token)) { - return token.value.length; - } - if (isTriggerToken(token)) { - return 1 + token.value.length; - } - return 1; // Reference/pinned are atomic -} - -function insertTextIntoTokens( - tokens: readonly PromptInputProps.InputToken[], - text: string, - position: number, - menus: readonly PromptInputProps.MenuDefinition[] -): PromptInputProps.InputToken[] { - const textTokens = textToTokens(text, menus); - const result: PromptInputProps.InputToken[] = []; - let currentPosition = 0; - let inserted = false; - - for (const token of tokens) { - const tokenLength = getTokenLength(token); - - if (!inserted && position >= currentPosition && position < currentPosition + tokenLength) { - if (isTextToken(token)) { - const offset = position - currentPosition; - if (offset > 0) { - result.push({ type: 'text', value: token.value.substring(0, offset) }); - } - result.push(...textTokens); - if (offset < token.value.length) { - result.push({ type: 'text', value: token.value.substring(offset) }); - } - } else if (isTriggerToken(token)) { - const offset = position - currentPosition; - if (offset === 0) { - result.push(...textTokens, token); - } else { - const valueOffset = offset - 1; - result.push({ - ...token, - value: token.value.substring(0, valueOffset) + text + token.value.substring(valueOffset), - }); - } - } - inserted = true; - } else if (!inserted && position === currentPosition) { - result.push(...textTokens, token); - inserted = true; - } else { - result.push(token); - } - - currentPosition += tokenLength; - } - - if (!inserted) { - result.push(...textTokens); - } - - // Merge adjacent text tokens - return result.reduce((merged, token) => { - const last = merged[merged.length - 1]; - if (isTextToken(token) && last && isTextToken(last)) { - last.value += token.value; - } else { - merged.push(token); - } - return merged; - }, []); -} +import { setCursorPosition } from '../core/cursor-manager'; export function insertTextIntoContentEditable( element: HTMLElement, text: string, cursorStart: number | undefined, - cursorEnd: number | undefined, - tokens: readonly PromptInputProps.InputToken[], - menus: readonly PromptInputProps.MenuDefinition[], - onChange: (detail: { value: string; tokens: PromptInputProps.InputToken[] }) => void, - tokensToText: (tokens: readonly PromptInputProps.InputToken[]) => string, - lastKnownCursorPosition: number, - lastKnownCursorPositionRef: React.MutableRefObject + cursorEnd: number | undefined ): void { element.focus(); - // Calculate pinned token offset - const positionAfterPinned = tokens.filter(isPinnedReferenceToken).length; + // Set cursor to insertion position + if (cursorStart !== undefined) { + setCursorPosition(element, cursorStart); + } + + // Get current selection + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return; + } - // Determine insertion position - const insertPosition = - cursorStart !== undefined - ? cursorStart === 0 - ? positionAfterPinned - : cursorStart - : Math.max(lastKnownCursorPosition, positionAfterPinned); + const range = selection.getRangeAt(0); - // Insert text and calculate final cursor position - const textTokens = textToTokens(text, menus); - const insertedLength = textTokens.reduce((sum, token) => sum + getTokenLength(token), 0); - const newTokens = insertTextIntoTokens(tokens, text, insertPosition, menus); - const finalPosition = - cursorEnd !== undefined ? (cursorEnd === 0 ? positionAfterPinned : cursorEnd) : insertPosition + insertedLength; + // Create text node with ONLY the text passed to insertText + const textNode = document.createTextNode(text); - // Update cursor position ref for unified restoration - if (lastKnownCursorPositionRef) { - lastKnownCursorPositionRef.current = finalPosition; - } + // Insert the node at the current cursor position + range.insertNode(textNode); - // Trigger state update and re-render - onChange({ value: tokensToText(newTokens), tokens: newTokens }); + // Trigger input event to let handleInput() process the changes + element.dispatchEvent(new Event('input', { bubbles: true })); + + // Set cursor position AFTER input event processing + requestAnimationFrame(() => { + if (cursorEnd !== undefined) { + setCursorPosition(element, cursorEnd); + } else { + const insertPosition = cursorStart ?? 0; + setCursorPosition(element, insertPosition + text.length); + } + }); } diff --git a/tsconfig.json b/tsconfig.json index 3df6e6b365..5646c86b3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,10 @@ "jsx": "react", "rootDir": "src", "outDir": "lib/components", - "incremental": true + "incremental": true, + "paths": { + "react-dom/client": ["./src/internal/vendor/react-dom-client-stub"] + } }, "include": ["src", "types"], "exclude": ["**/__tests__/**", "src/test-utils/**", "**/__integ__/**", "**/__a11y__/**", "**/__motion__/**"] From 67897057bece9944da87c91cc494aad60e64a218 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Fri, 6 Mar 2026 10:41:54 +0100 Subject: [PATCH 5/9] Webpack update for react-dom/client --- pages/webpack.config.base.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/webpack.config.base.cjs b/pages/webpack.config.base.cjs index 9350296f59..23ce441848 100644 --- a/pages/webpack.config.base.cjs +++ b/pages/webpack.config.base.cjs @@ -51,7 +51,7 @@ module.exports = ({ } : { '~mount': path.resolve(__dirname, './app/mount/react16.ts'), - 'react-dom/client': path.resolve(__dirname, '../lib/components/internal/vendor/react-dom-client-stub.js'), + 'react-dom/client': path.resolve(componentsPath, 'internal/vendor/react-dom-client-stub.js'), }), }, }, From 0565c332779df5630e585a4ccce389c56e73bb5d Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Fri, 6 Mar 2026 11:27:30 +0100 Subject: [PATCH 6/9] Further react-dom/client changes to fix builds --- pages/webpack.config.base.cjs | 2 +- src/prompt-input/core/token-renderer.tsx | 2 +- tsconfig.json | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pages/webpack.config.base.cjs b/pages/webpack.config.base.cjs index 23ce441848..910db2c592 100644 --- a/pages/webpack.config.base.cjs +++ b/pages/webpack.config.base.cjs @@ -48,10 +48,10 @@ module.exports = ({ react: 'react18', 'react-dom': 'react-dom18', 'react-dom/client': 'react-dom18/client', + [path.resolve(componentsPath, 'internal/vendor/react-dom-client-stub.js')]: 'react-dom18/client', } : { '~mount': path.resolve(__dirname, './app/mount/react16.ts'), - 'react-dom/client': path.resolve(componentsPath, 'internal/vendor/react-dom-client-stub.js'), }), }, }, diff --git a/src/prompt-input/core/token-renderer.tsx b/src/prompt-input/core/token-renderer.tsx index b8fa4a173b..5a54fdc9b9 100644 --- a/src/prompt-input/core/token-renderer.tsx +++ b/src/prompt-input/core/token-renderer.tsx @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { createRoot, Root } from 'react-dom/client'; +import { createRoot, Root } from '../../internal/vendor/react-dom-client-stub'; import Token from '../../token/internal'; import { PromptInputProps } from '../interfaces'; import { ELEMENT_TYPES, SPECIAL_CHARS } from './constants'; diff --git a/tsconfig.json b/tsconfig.json index 5646c86b3f..3df6e6b365 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,10 +12,7 @@ "jsx": "react", "rootDir": "src", "outDir": "lib/components", - "incremental": true, - "paths": { - "react-dom/client": ["./src/internal/vendor/react-dom-client-stub"] - } + "incremental": true }, "include": ["src", "types"], "exclude": ["**/__tests__/**", "src/test-utils/**", "**/__integ__/**", "**/__a11y__/**", "**/__motion__/**"] From 5a47f166a66916f4b5b132d69c0e3af2055b11a3 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Fri, 6 Mar 2026 15:26:28 +0100 Subject: [PATCH 7/9] Update aria-attributes for failing tests --- pages/prompt-input/shortcuts.page.tsx | 4 +-- .../__tests__/prompt-input.test.tsx | 2 +- src/prompt-input/components/token-mode.tsx | 1 - src/prompt-input/interfaces.ts | 4 +-- src/prompt-input/internal.tsx | 32 +++++++++++++------ 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/pages/prompt-input/shortcuts.page.tsx b/pages/prompt-input/shortcuts.page.tsx index 3911977d11..057b445ae1 100644 --- a/pages/prompt-input/shortcuts.page.tsx +++ b/pages/prompt-input/shortcuts.page.tsx @@ -492,14 +492,14 @@ export default function PromptInputShortcutsPage() { tokens={tokens} maxMenuHeight={400} onChange={event => { - setTokens(event.detail.tokens); + setTokens(event.detail.tokens ?? []); setPlainTextValue(event.detail.value ?? ''); }} onAction={({ detail }) => { setExtractedText(detail.value ?? ''); // Keep mode token (first pinned reference from useAtStart menu) after submission - const modeToken = detail.tokens.find( + const modeToken = detail.tokens?.find( (token): token is PromptInputProps.ReferenceToken => token.type === 'reference' && token.pinned === true ); diff --git a/src/prompt-input/__tests__/prompt-input.test.tsx b/src/prompt-input/__tests__/prompt-input.test.tsx index 7f084ea517..92be6a6873 100644 --- a/src/prompt-input/__tests__/prompt-input.test.tsx +++ b/src/prompt-input/__tests__/prompt-input.test.tsx @@ -274,7 +274,7 @@ describe('events', () => { wrapper.setTextareaValue('updated value'); - expect(onChange).toHaveBeenCalledWith({ value: 'updated value', tokens: [] }); + expect(onChange).toHaveBeenCalledWith({ value: 'updated value' }); }); test('fire an action event on action button click with correct parameters', () => { diff --git a/src/prompt-input/components/token-mode.tsx b/src/prompt-input/components/token-mode.tsx index cf425b9481..ff51100194 100644 --- a/src/prompt-input/components/token-mode.tsx +++ b/src/prompt-input/components/token-mode.tsx @@ -97,7 +97,6 @@ export default function TokenMode({ className={testutilStyles['content-editable']} aria-controls={menuIsOpen ? menuListId : undefined} aria-activedescendant={highlightedMenuOptionId} - aria-expanded={menuIsOpen} onInput={handleInput} {...editableElementAttributes} /> diff --git a/src/prompt-input/interfaces.ts b/src/prompt-input/interfaces.ts index 01aaee9e4a..5ba443df77 100644 --- a/src/prompt-input/interfaces.ts +++ b/src/prompt-input/interfaces.ts @@ -357,12 +357,12 @@ export namespace PromptInputProps { export interface ChangeDetail { value: string; - tokens: InputToken[]; + tokens?: InputToken[]; } export interface ActionDetail { value: string; - tokens: InputToken[]; + tokens?: InputToken[]; } export interface MenuItemSelectDetail { diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index a2cbec59d9..cff06420f7 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -227,7 +227,6 @@ const InternalPromptInput = React.forwardRef( textarea.dispatchEvent(new Event('input', { bubbles: true })); fireNonCancelableEvent(onChange, { value: newValue, - tokens: [], }); } }, @@ -272,8 +271,10 @@ const InternalPromptInput = React.forwardRef( if (isTokenMode) { // Use requestAnimationFrame to ensure DOM has updated requestAnimationFrame(() => adjustInputHeight()); + } else { + adjustInputHeight(); } - }, [isTokenMode, tokens, adjustInputHeight]); + }, [isTokenMode, tokens, adjustInputHeight, value]); // Helper to get plain text value from tokens or value prop const getPlainTextValue = useStableCallback(() => { @@ -380,17 +381,24 @@ const InternalPromptInput = React.forwardRef( event.currentTarget.form.requestSubmit(); } event.preventDefault(); - fireNonCancelableEvent(onAction, { value: getPlainTextValue(), tokens: [...(tokens ?? [])] }); + fireNonCancelableEvent(onAction, { + value: getPlainTextValue(), + ...(isTokenMode && { tokens: [...(tokens ?? [])] }), + }); } }; const handleTextareaChange = (event: React.ChangeEvent) => { - const newTokens = isTokenMode ? [...(tokens ?? [])] : []; - markTokensAsSent(newTokens); - fireNonCancelableEvent(onChange, { + if (isTokenMode) { + markTokensAsSent([...(tokens ?? [])]); + } + const detail: PromptInputProps.ChangeDetail = { value: event.target.value, - tokens: newTokens, - }); + }; + if (isTokenMode) { + detail.tokens = [...(tokens ?? [])]; + } + fireNonCancelableEvent(onChange, detail); adjustInputHeight(); }; @@ -783,6 +791,9 @@ const InternalPromptInput = React.forwardRef( isEmpty: !menuItemsState || menuItemsState.items.length === 0, recoveryText: i18nStrings?.menuRecoveryText, errorIconAriaLabel: i18nStrings?.menuErrorIconAriaLabel, + loadingText: i18nStrings?.menuLoadingText, + finishedText: i18nStrings?.menuFinishedText, + errorText: i18nStrings?.menuErrorText, onRecoveryClick: () => { if (menuLoadMoreHandlers) { menuLoadMoreHandlers.fireLoadMoreOnRecoveryClick(); @@ -812,7 +823,10 @@ const InternalPromptInput = React.forwardRef( iconSvg={actionButtonIconSvg} iconAlt={actionButtonIconAlt} onClick={() => { - fireNonCancelableEvent(onAction, { value: getPlainTextValue(), tokens: [...(tokens ?? [])] }); + fireNonCancelableEvent(onAction, { + value: getPlainTextValue(), + ...(isTokenMode && { tokens: [...(tokens ?? [])] }), + }); }} variant="icon" /> From 2af37ffcacfcd78c86c9e7896093b9105bb1cb77 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Fri, 6 Mar 2026 15:57:51 +0100 Subject: [PATCH 8/9] Temp: Add React 16 support --- src/internal/vendor/react-dom-client-stub.ts | 21 +++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/internal/vendor/react-dom-client-stub.ts b/src/internal/vendor/react-dom-client-stub.ts index 8a2a29357e..30e7b79af0 100644 --- a/src/internal/vendor/react-dom-client-stub.ts +++ b/src/internal/vendor/react-dom-client-stub.ts @@ -1,23 +1,30 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import ReactDOM from 'react-dom'; + // Stub for react-dom/client when React 18 is not available -// This allows the build to pass for React 16/17 while token mode features are disabled +// This provides React 16/17 compatibility using the legacy render API export interface Root { render: (element: any) => void; unmount: () => void; } -// Stub createRoot that does nothing (token mode won't work in React 16/17) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function createRoot(_container?: HTMLElement): Root { +// Map to track which containers have been rendered to +const containerMap = new Map(); + +// Stub createRoot that uses legacy ReactDOM.render for React 16/17 +export function createRoot(container: HTMLElement): Root { + containerMap.set(container, true); + return { - render: () => { - // No-op in React 16/17 + render: (element: any) => { + ReactDOM.render(element, container); }, unmount: () => { - // No-op in React 16/17 + ReactDOM.unmountComponentAtNode(container); + containerMap.delete(container); }, }; } From 6ec9bce524d53d2414a8bb189335d7d348c54700 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Mon, 9 Mar 2026 16:54:51 +0100 Subject: [PATCH 9/9] Fully support React 16 with a dev warning, minor bug fixes and visual affordance for triggers --- src/internal/vendor/react-dom-client-stub.ts | 12 ++ src/prompt-input/core/event-handlers.ts | 67 ++++++--- src/prompt-input/core/token-renderer.tsx | 5 +- src/prompt-input/core/trigger-utils.ts | 137 ++++++++++++++++++ src/prompt-input/internal.tsx | 19 ++- src/prompt-input/styles.scss | 5 +- .../tokens/use-editable-tokens.ts | 62 +++++--- 7 files changed, 258 insertions(+), 49 deletions(-) create mode 100644 src/prompt-input/core/trigger-utils.ts diff --git a/src/internal/vendor/react-dom-client-stub.ts b/src/internal/vendor/react-dom-client-stub.ts index 30e7b79af0..0ef9c185dc 100644 --- a/src/internal/vendor/react-dom-client-stub.ts +++ b/src/internal/vendor/react-dom-client-stub.ts @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'; +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + // Stub for react-dom/client when React 18 is not available // This provides React 16/17 compatibility using the legacy render API @@ -14,8 +16,18 @@ export interface Root { // Map to track which containers have been rendered to const containerMap = new Map(); +let hasWarned = false; + // Stub createRoot that uses legacy ReactDOM.render for React 16/17 export function createRoot(container: HTMLElement): Root { + if (!hasWarned) { + warnOnce( + 'PromptInput', + 'Token mode features (menus, tokens) are using React 16/17 compatibility mode. For optimal performance and features, upgrade to React 18+.' + ); + hasWarned = true; + } + containerMap.set(container, true); return { diff --git a/src/prompt-input/core/event-handlers.ts b/src/prompt-input/core/event-handlers.ts index 2182f43a38..25b55c4ccf 100644 --- a/src/prompt-input/core/event-handlers.ts +++ b/src/prompt-input/core/event-handlers.ts @@ -17,16 +17,18 @@ import { import { MenuItemsHandlers, MenuItemsState } from './menu-state'; import { extractTokensFromDOM, getPromptText } from './token-operations'; import { findAdjacentToken } from './token-utils'; +import { handleSpaceInOpenMenu } from './trigger-utils'; import { isBreakToken, isHTMLElement, isReferenceToken, isTextNode, isTextToken, isTriggerToken } from './type-guards'; // TYPES export type { EditableState }; -export interface KeyboardHandlerDeps { +export interface KeyboardHandlerProps { getMenuOpen: () => boolean; getMenuItemsState: () => MenuItemsState | null; getMenuItemsHandlers: () => MenuItemsHandlers | null; + getMenuStatusType?: () => PromptInputProps.MenuDefinition['statusType']; onAction?: (detail: PromptInputProps.ActionDetail) => void; tokensToText?: (tokens: readonly PromptInputProps.InputToken[]) => string; tokens?: readonly PromptInputProps.InputToken[]; @@ -35,15 +37,18 @@ export interface KeyboardHandlerDeps { i18nStrings?: PromptInputProps.I18nStrings; disabled?: boolean; readOnly?: boolean; + editableState?: EditableState; + editableElementRef?: React.RefObject; + lastKnownCursorPositionRef?: React.MutableRefObject; } // KEYBOARD HANDLERS -export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { +export function createKeyboardHandlers(props: KeyboardHandlerProps) { function handleMenuNavigation(event: React.KeyboardEvent): boolean { - const menuItemsState = deps.getMenuItemsState(); - const menuItemsHandlers = deps.getMenuItemsHandlers(); - const menuOpen = deps.getMenuOpen(); + const menuItemsState = props.getMenuItemsState(); + const menuItemsHandlers = props.getMenuItemsHandlers(); + const menuOpen = props.getMenuOpen(); if (!menuOpen || !menuItemsHandlers || !menuItemsState) { return false; @@ -62,9 +67,21 @@ export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { return menuItemsHandlers.selectHighlightedOptionWithKeyboard(); } + if (event.key === ' ') { + return handleSpaceInOpenMenu(event, { + menuItemsState, + menuItemsHandlers, + getMenuStatusType: props.getMenuStatusType, + closeMenu: props.closeMenu, + editableElementRef: props.editableElementRef, + lastKnownCursorPositionRef: props.lastKnownCursorPositionRef, + editableState: props.editableState, + }); + } + if (event.key === 'Escape') { event.preventDefault(); - deps.closeMenu(); + props.closeMenu(); return true; } @@ -77,7 +94,7 @@ export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { } // Don't submit if disabled or readonly (match textarea behavior) - if (deps.disabled || deps.readOnly) { + if (props.disabled || props.readOnly) { event.preventDefault(); return; } @@ -93,10 +110,10 @@ export function createKeyboardHandlers(deps: KeyboardHandlerDeps) { } event.preventDefault(); - const plainText = deps.tokensToText ? deps.tokensToText(deps.tokens ?? []) : getPromptText(deps.tokens ?? []); + const plainText = props.tokensToText ? props.tokensToText(props.tokens ?? []) : getPromptText(props.tokens ?? []); - if (deps.onAction) { - deps.onAction({ value: plainText, tokens: [...(deps.tokens ?? [])] }); + if (props.onAction) { + props.onAction({ value: plainText, tokens: [...(props.tokens ?? [])] }); } } @@ -477,10 +494,12 @@ export function handleSpaceAfterClosedTrigger( menuOpen: boolean, triggerValueWhenClosed: string, editableState: EditableState, + ignoreCursorDetection: React.MutableRefObject, menus?: readonly PromptInputProps.MenuDefinition[] ): boolean { - // Only handle space key when menu is closed and we have a saved trigger length - if (event.key !== ' ' || menuOpen || !triggerValueWhenClosed) { + // Only handle space key when menu is closed + // triggerValueWhenClosed can be empty string (trigger with no filter) or non-empty (trigger with filter) + if (event.key !== ' ' || menuOpen) { return false; } @@ -506,15 +525,15 @@ export function handleSpaceAfterClosedTrigger( triggerElement = parent; const textLength = range.startContainer.textContent?.length || 0; cursorAtEnd = range.startOffset === textLength; - - // Extract filter text (everything after trigger char) - const fullText = triggerElement.textContent || ''; - const filterText = fullText.substring(1); - - // Only handle if filter text matches saved length (space hasn't been added yet) - // If it's longer, the space was already added and we shouldn't handle it again - if (filterText.length !== triggerValueWhenClosed.length) { - return false; + } + } else if (isHTMLElement(range.startContainer)) { + // Cursor might be positioned in the paragraph after the trigger + const container = range.startContainer; + if (range.startOffset > 0) { + const prevNode = container.childNodes[range.startOffset - 1]; + if (isHTMLElement(prevNode) && getTokenType(prevNode) === ELEMENT_TYPES.TRIGGER) { + triggerElement = prevNode; + cursorAtEnd = true; } } } @@ -599,6 +618,12 @@ export function handleSpaceAfterClosedTrigger( sel.addRange(cursorRange); } + // Prevent cursor detection from reopening the menu + ignoreCursorDetection.current = true; + setTimeout(() => { + ignoreCursorDetection.current = false; + }, 100); + // Trigger input event to extract tokens and update state editableElement.dispatchEvent(new Event('input', { bubbles: true })); diff --git a/src/prompt-input/core/token-renderer.tsx b/src/prompt-input/core/token-renderer.tsx index 5a54fdc9b9..208b5e1187 100644 --- a/src/prompt-input/core/token-renderer.tsx +++ b/src/prompt-input/core/token-renderer.tsx @@ -310,18 +310,19 @@ export function renderTokensToDOM( } else if (isTriggerToken(token)) { let span: HTMLElement; const isNewTrigger = !token.id || !existingTriggers.has(token.id); + const hasFilterText = token.value.length > 0; if (token.id && existingTriggers.has(token.id)) { // Reuse existing trigger element and update its content span = existingTriggers.get(token.id)!; span.textContent = token.triggerChar + token.value; - span.className = styles['trigger-token']; + span.className = hasFilterText ? styles['trigger-token'] : ''; existingTriggers.delete(token.id); } else { // Create new trigger element span = document.createElement('span'); span.setAttribute('data-type', ELEMENT_TYPES.TRIGGER); - span.className = styles['trigger-token']; + span.className = hasFilterText ? styles['trigger-token'] : ''; if (token.id) { span.setAttribute('data-id', token.id); } diff --git a/src/prompt-input/core/trigger-utils.ts b/src/prompt-input/core/trigger-utils.ts new file mode 100644 index 0000000000..fedf8e67d6 --- /dev/null +++ b/src/prompt-input/core/trigger-utils.ts @@ -0,0 +1,137 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PromptInputProps } from '../interfaces'; +import { EditableState } from '../tokens/use-editable-tokens'; +import { ELEMENT_TYPES } from './constants'; +import { getCursorPosition, positionAfter } from './cursor-manager'; +import { getTokenType, insertAfter } from './dom-utils'; +import { MenuItemsHandlers, MenuItemsState } from './menu-state'; +import { isTextNode } from './type-guards'; + +import styles from '../styles.css.js'; + +interface TriggerSpaceHandlerProps { + menuItemsState: MenuItemsState; + menuItemsHandlers: MenuItemsHandlers; + getMenuStatusType?: () => PromptInputProps.MenuDefinition['statusType']; + closeMenu: () => void; + editableElementRef?: React.RefObject; + lastKnownCursorPositionRef?: React.MutableRefObject; + editableState?: EditableState; +} + +/** + * Finds the trigger element at the current cursor position + */ +export function findTriggerAtCursor(): HTMLElement | null { + const selection = window.getSelection(); + if (!selection?.rangeCount) { + return null; + } + + const range = selection.getRangeAt(0); + const parent = isTextNode(range.startContainer) ? range.startContainer.parentElement : null; + return parent && getTokenType(parent) === ELEMENT_TYPES.TRIGGER ? parent : null; +} + +/** + * Finalizes space insertion after a trigger by positioning cursor and updating refs + */ +function finalizeSpaceInsertion( + spaceNode: Text, + props: Pick +): void { + positionAfter(spaceNode); + + if (props.editableElementRef?.current && props.lastKnownCursorPositionRef) { + props.lastKnownCursorPositionRef.current = getCursorPosition(props.editableElementRef.current); + } + if (props.editableState) { + props.editableState.skipCursorRestore = true; + } + + queueMicrotask(() => { + const editableElement = spaceNode.parentElement?.closest('[contenteditable="true"]') as HTMLElement; + if (editableElement) { + editableElement.dispatchEvent(new Event('input', { bubbles: true })); + } + }); +} + +/** + * Handles space key press when menu is open + * Returns true if handled, false to allow default behavior + */ +export function handleSpaceInOpenMenu(event: React.KeyboardEvent, props: TriggerSpaceHandlerProps): boolean { + const { menuItemsState, menuItemsHandlers, getMenuStatusType, closeMenu } = props; + const items = menuItemsState.items; + const statusType = getMenuStatusType?.() ?? 'finished'; + const isLoading = statusType === 'loading' || statusType === 'pending'; + + const triggerElement = findTriggerAtCursor(); + if (!triggerElement) { + return false; + } + + const triggerText = triggerElement.textContent || ''; + const triggerChar = triggerText[0]; + const filterText = triggerText.substring(1); + + // Case 1: Single selectable option (not loading) - select it + const selectableItems = items.filter(item => item.type !== 'parent'); + if (selectableItems.length === 1 && !isLoading) { + event.preventDefault(); + return menuItemsHandlers.selectHighlightedOptionWithKeyboard(); + } + + // Case 2: Double space - close menu, clean filter, add ONE space + if (filterText.endsWith(' ')) { + event.preventDefault(); + closeMenu(); + + const cleanFilterText = filterText.trimEnd(); + triggerElement.textContent = triggerChar + cleanFilterText; + triggerElement.className = cleanFilterText.length > 0 ? styles['trigger-token'] : ''; + + const oneSpace = document.createTextNode(' '); + insertAfter(oneSpace, triggerElement); + finalizeSpaceInsertion(oneSpace, props); + + return true; + } + + // Case 3: Empty filter - close menu, add space as plain text + if (filterText === '') { + event.preventDefault(); + closeMenu(); + + const spaceNode = document.createTextNode(' '); + insertAfter(spaceNode, triggerElement); + finalizeSpaceInsertion(spaceNode, props); + + return true; + } + + // Default: Allow space in filter for multi-word filtering + return false; +} + +/** + * Checks if a trigger needs immediate re-rendering due to styling changes + */ +export function needsImmediateRenderForStyling( + newTriggers: PromptInputProps.TriggerToken[], + oldTriggers: PromptInputProps.TriggerToken[] +): boolean { + return newTriggers.some((newT, i) => { + const oldT = oldTriggers[i]; + if (!oldT) { + return false; + } + // Render when transitioning between empty and non-empty filter (styling change) + const wasEmpty = oldT.value.length === 0; + const isEmpty = newT.value.length === 0; + return wasEmpty !== isEmpty; + }); +} diff --git a/src/prompt-input/internal.tsx b/src/prompt-input/internal.tsx index cff06420f7..4a44e0b3e8 100644 --- a/src/prompt-input/internal.tsx +++ b/src/prompt-input/internal.tsx @@ -498,6 +498,7 @@ const InternalPromptInput = React.forwardRef( shortcuts.menuIsOpen, shortcuts.triggerValueWhenClosed, editableState, + ignoreCursorDetection, menus ) ) { @@ -627,6 +628,7 @@ const InternalPromptInput = React.forwardRef( getMenuOpen: () => menuStateRef.current.isOpen, getMenuItemsState: () => menuStateRef.current.itemsState, getMenuItemsHandlers: () => menuStateRef.current.itemsHandlers, + getMenuStatusType: () => activeMenu?.statusType, onAction: onAction ? detail => fireNonCancelableEvent(onAction, detail) : undefined, tokensToText, tokens, @@ -645,8 +647,23 @@ const InternalPromptInput = React.forwardRef( i18nStrings, disabled, readOnly, + editableState, + editableElementRef, + lastKnownCursorPositionRef, }); - }, [onAction, tokensToText, tokens, ignoreCursorDetection, shortcuts, i18nStrings, disabled, readOnly]); + }, [ + onAction, + tokensToText, + tokens, + ignoreCursorDetection, + shortcuts, + i18nStrings, + disabled, + readOnly, + activeMenu, + editableState, + lastKnownCursorPositionRef, + ]); // Menu load more controller const menuLoadMoreResult = useMenuLoadMore({ diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss index 1806d4e31f..c2fb105756 100644 --- a/src/prompt-input/styles.scss +++ b/src/prompt-input/styles.scss @@ -326,8 +326,9 @@ $invalid-border-offset: constants.$invalid-control-left-padding; } .trigger-token { - font-style: italic; - text-decoration: underline dashed; + text-decoration: underline dashed currentColor; + text-decoration-thickness: awsui.$border-divider-list-width; + text-underline-offset: awsui.$space-xxxs; } // Paragraph elements - reset browser default margins/padding diff --git a/src/prompt-input/tokens/use-editable-tokens.ts b/src/prompt-input/tokens/use-editable-tokens.ts index f21f5ed3e6..d8d4150f32 100644 --- a/src/prompt-input/tokens/use-editable-tokens.ts +++ b/src/prompt-input/tokens/use-editable-tokens.ts @@ -21,6 +21,7 @@ import { import { extractTokensFromDOM, getPromptText } from '../core/token-operations'; import { renderTokensToDOM } from '../core/token-renderer'; import { enforcePinnedTokenOrdering } from '../core/token-utils'; +import { needsImmediateRenderForStyling } from '../core/trigger-utils'; import { isBreakToken, isBRElement, @@ -204,34 +205,49 @@ export function useEditableTokens({ const newTriggers = extractedTokens.filter(isTriggerToken); const oldTriggers = lastEmittedTokensRef.current?.filter(isTriggerToken) || []; - if (newTriggers.length > oldTriggers.length) { - // New trigger detected - render immediately to create trigger element + // Check if we need immediate rendering + const isNewTrigger = newTriggers.length > oldTriggers.length; + const hasStylingChange = needsImmediateRenderForStyling( + newTriggers.filter(isTriggerToken), + oldTriggers.filter(isTriggerToken) + ); + + if (isNewTrigger || hasStylingChange) { + // Save cursor position before rendering + const savedCursorPos = getCursorPosition(elementRef.current); + + // Render immediately to update trigger element renderTokensToDOM(extractedTokens, elementRef.current, reactContainersRef.current, { disabled, readOnly }); - // Find the new trigger (not in oldTriggers) - const oldTriggerIds = new Set(oldTriggers.map(t => (isTriggerToken(t) ? t.id : undefined))); - const newTrigger = newTriggers.find(t => isTriggerToken(t) && !oldTriggerIds.has(t.id)); - - // Position cursor inside the new trigger element - if (newTrigger && isTriggerToken(newTrigger) && newTrigger.id) { - const triggerElements = findElements(elementRef.current, { - tokenType: ELEMENT_TYPES.TRIGGER, - tokenId: newTrigger.id, - }); - if (triggerElements.length > 0) { - const triggerElement = triggerElements[0]; - const triggerTextNode = triggerElement.firstChild; - if (triggerTextNode && isTextNode(triggerTextNode)) { - const range = document.createRange(); - range.setStart(triggerTextNode, triggerTextNode.textContent?.length || 0); - range.collapse(true); - const selection = window.getSelection(); - if (selection) { - selection.removeAllRanges(); - selection.addRange(range); + if (isNewTrigger) { + // Find the new trigger (not in oldTriggers) + const oldTriggerIds = new Set(oldTriggers.map(t => (isTriggerToken(t) ? t.id : undefined))); + const newTrigger = newTriggers.find(t => isTriggerToken(t) && !oldTriggerIds.has(t.id)); + + // Position cursor inside the new trigger element + if (newTrigger && isTriggerToken(newTrigger) && newTrigger.id) { + const triggerElements = findElements(elementRef.current, { + tokenType: ELEMENT_TYPES.TRIGGER, + tokenId: newTrigger.id, + }); + if (triggerElements.length > 0) { + const triggerElement = triggerElements[0]; + const triggerTextNode = triggerElement.firstChild; + if (triggerTextNode && isTextNode(triggerTextNode)) { + const range = document.createRange(); + range.setStart(triggerTextNode, triggerTextNode.textContent?.length || 0); + range.collapse(true); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } } } } + } else { + // Styling change only - restore cursor to saved position + setCursorPosition(elementRef.current, savedCursorPos); } }