| // Copyright 2026 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Platform from '../../core/platform/platform.js'; |
| import type * as SDK from '../../core/sdk/sdk.js'; |
| import type * as ChangeTracker from '../change_tracker/change_tracker.js'; |
| import type * as CommentManager from '../comment_manager/comment_manager.js'; |
| |
| type Tracker = ChangeTracker.ChangeTracker.ChangeTracker|undefined; |
| |
| /** |
| * Maximum number of characters of page controlled data (tag names, attribute names and values, |
| * text node values and HTML) that a change description embeds. The inspected page fully controls |
| * these strings and `Changed HTML from ... to ...` in particular can otherwise hold on to the |
| * entire subtree of an element. |
| */ |
| export const MAX_VALUE_LENGTH = 100; |
| |
| /** Shortens page controlled data so that both its start and its end stay recognizable. */ |
| function trimValue(value: string): string { |
| return Platform.StringUtilities.trimMiddle(value, MAX_VALUE_LENGTH); |
| } |
| |
| function buildAnchor(node: SDK.DOMModel.DOMNode, |
| selector = 'element'): CommentManager.CommentManager.CommentAnchorSignature { |
| return { |
| vePath: 'Panel: elements > Tree: elements > TreeItem', |
| textSignature: selector, |
| node: { |
| backendNodeId: node.backendNodeId(), |
| targetId: node.domModel().target().id(), |
| }, |
| }; |
| } |
| |
| /** |
| * Parses a raw attribute string (e.g. 'class="btn"', 'data-id=123', or 'disabled') |
| * into its attribute name and unquoted value for single-attribute inline edits. |
| */ |
| function parseAttributeText(text: string): {name: string, value: string} { |
| const trimmed = text.trim(); |
| const eqIdx = trimmed.indexOf('='); |
| if (eqIdx === -1) { |
| return {name: trimmed, value: ''}; |
| } |
| const name = trimmed.slice(0, eqIdx).trim(); |
| let value = trimmed.slice(eqIdx + 1).trim(); |
| if (value.length >= 2 && |
| ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) { |
| value = value.slice(1, -1); |
| } |
| return {name, value}; |
| } |
| |
| function describeAddAttribute(attributeName: string, value?: string): string { |
| if (value !== undefined && value !== '') { |
| return `Added attribute ${trimValue(attributeName)}="${trimValue(value)}"`; |
| } |
| return `Added attribute "${trimValue(attributeName)}"`; |
| } |
| |
| function describeRemoveAttribute(attributeName: string): string { |
| return `Removed attribute "${trimValue(attributeName)}"`; |
| } |
| |
| /** |
| * Describes a modification of an existing attribute, which covers renaming the attribute, changing |
| * its value, or both at once. `oldValue` is `undefined` if the previous value is unknown. |
| */ |
| function describeAttributeChange( |
| oldAttributeName: string, |
| oldValue: string|undefined, |
| newAttributeName: string, |
| newValue: string, |
| ): string { |
| if (oldAttributeName !== newAttributeName) { |
| if (oldValue !== undefined && oldValue !== newValue) { |
| return `Renamed attribute "${trimValue(oldAttributeName)}"="${trimValue(oldValue)}" to "${ |
| trimValue(newAttributeName)}"="${trimValue(newValue)}"`; |
| } |
| return `Renamed attribute "${trimValue(oldAttributeName)}" to "${trimValue(newAttributeName)}"`; |
| } |
| if (oldValue !== undefined) { |
| return `Changed attribute "${trimValue(newAttributeName)}" from "${trimValue(oldValue)}" to "${ |
| trimValue(newValue)}"`; |
| } |
| return `Changed attribute "${trimValue(newAttributeName)}" to "${trimValue(newValue)}"`; |
| } |
| |
| export interface AttributeEdit { |
| /** Name of the edited attribute, or an empty string when a new attribute is added. */ |
| attributeName: string; |
| /** Raw attribute text before the edit, if there was any. */ |
| oldText: string|null; |
| /** Raw attribute text the user committed. Empty when the attribute is removed. */ |
| newText: string; |
| } |
| |
| function describeAttributeEdit({attributeName, oldText, newText}: AttributeEdit): string { |
| const parsedNew = parseAttributeText(newText); |
| const parsedOld = oldText !== null ? parseAttributeText(oldText) : undefined; |
| |
| if (!newText.trim()) { |
| return describeRemoveAttribute(attributeName.trim()); |
| } |
| if (!attributeName.trim()) { |
| return describeAddAttribute(parsedNew.name, parsedNew.value); |
| } |
| return describeAttributeChange( |
| attributeName.trim(), |
| parsedOld?.value, |
| parsedNew.name || attributeName.trim(), |
| parsedNew.value, |
| ); |
| } |
| |
| /** |
| * These strings are for the consumption by AI agents, they don't need to be translated. |
| */ |
| function describeTagNameEdit(oldTagName: string, newTagName: string): string { |
| return `Renamed tag from <${trimValue(oldTagName)}> to <${trimValue(newTagName)}>`; |
| } |
| |
| function describeTextNodeEdit(oldText: string, newText: string): string { |
| return `Changed text from "${trimValue(oldText)}" to "${trimValue(newText)}"`; |
| } |
| |
| function describeNodeRemoval(tagName: string): string { |
| return `Removed node <${trimValue(tagName)}>`; |
| } |
| |
| function describeHTMLEdit(oldValue?: string, newValue?: string): string { |
| if (oldValue !== undefined && newValue !== undefined) { |
| return `Changed HTML from "${trimValue(oldValue)}" to "${trimValue(newValue)}"`; |
| } |
| if (newValue !== undefined) { |
| return `Changed HTML to "${trimValue(newValue)}"`; |
| } |
| return 'Edited HTML'; |
| } |
| |
| function describeVisibilityToggle(tagName: string, hidden: boolean): string { |
| return hidden ? `Hid element <${trimValue(tagName)}>` : `Unhid element <${trimValue(tagName)}>`; |
| } |
| |
| function describeNodeDuplication(tagName: string): string { |
| return `Duplicated node <${trimValue(tagName)}>`; |
| } |
| |
| function describeNodeMove(tagName: string, directionUp: boolean): string { |
| return `Moved node <${trimValue(tagName)}> ${directionUp ? 'up' : 'down'}`; |
| } |
| |
| function describeNodeDrop(tagName: string): string { |
| return `Moved node <${trimValue(tagName)}> via drag and drop`; |
| } |
| |
| function describeNodePaste(tagName: string, isCut: boolean): string { |
| return isCut ? `Pasted (moved) node <${trimValue(tagName)}>` : `Pasted node <${trimValue(tagName)}>`; |
| } |
| |
| export function trackAttributeEdit(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined, |
| edit: AttributeEdit): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeAttributeEdit(edit), buildAnchor(node, selector)); |
| } |
| |
| export function trackTagNameEdit(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined, |
| oldTagName: string, newTagName: string): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeTagNameEdit(oldTagName, newTagName), buildAnchor(node, selector)); |
| } |
| |
| export function trackTextNodeEdit(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined, |
| oldText: string, newText: string): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeTextNodeEdit(oldText, newText), buildAnchor(node, selector)); |
| } |
| |
| export function trackNodeRemoval(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeNodeRemoval(node.nodeName().toLowerCase()), buildAnchor(node, selector)); |
| } |
| |
| export function trackHTMLEdit(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined, |
| oldValue: string, newValue: string): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeHTMLEdit(oldValue, newValue), buildAnchor(node, selector)); |
| } |
| |
| export function trackVisibilityToggle(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined, |
| hidden: boolean): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeVisibilityToggle(node.nodeName().toLowerCase(), hidden), buildAnchor(node, selector)); |
| } |
| |
| export function trackNodeDuplication(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeNodeDuplication(node.nodeName().toLowerCase()), buildAnchor(node, selector)); |
| } |
| |
| export function trackNodeMove(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined, |
| directionUp: boolean): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeNodeMove(node.nodeName().toLowerCase(), directionUp), buildAnchor(node, selector)); |
| } |
| |
| export function trackNodeDrop(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeNodeDrop(node.nodeName().toLowerCase()), buildAnchor(node, selector)); |
| } |
| |
| export function trackNodePaste(tracker: Tracker, node: SDK.DOMModel.DOMNode, selector: string|undefined, |
| isCut: boolean): void { |
| if (!tracker?.isTracking) { |
| return; |
| } |
| tracker.trackChange(describeNodePaste(node.nodeName().toLowerCase(), isCut), buildAnchor(node, selector)); |
| } |