mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
feat(editor): support linked editing for matching HTML tags (#2993)
Editing the name in an opening or closing HTML tag renames the matching tag of the same element as you type, from the @lezer/html syntax tree, in one undoable transaction. Covers HTML, Vue, Angular templates, JSP, Django and Astro (ERB is token-based and stays out). Smart Keys gains Simultaneous <tag></tag> editing (on by default). Review fix: the Gutter Icons bookmarks group keeps its real gutter class. Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com>
This commit is contained in:
@@ -270,6 +270,8 @@ pub struct SmartKeysSettings {
|
||||
pub pair_quotes: bool,
|
||||
pub surround_selection: bool,
|
||||
pub tab_out_of_pair: bool,
|
||||
/// Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag.
|
||||
pub simultaneous_tag_editing: bool,
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub escape_on_paste: bool,
|
||||
@@ -289,6 +291,7 @@ impl Default for SmartKeysSettings {
|
||||
pair_quotes: true,
|
||||
surround_selection: true,
|
||||
tab_out_of_pair: true,
|
||||
simultaneous_tag_editing: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
escape_on_paste: true,
|
||||
smart_indent: true,
|
||||
|
||||
@@ -9605,6 +9605,8 @@ export type SmartKeysSettings = {
|
||||
pairQuotes?: boolean,
|
||||
surroundSelection?: boolean,
|
||||
tabOutOfPair?: boolean,
|
||||
/** Simultaneous `<tag></tag>` editing: renaming a tag renames its matching tag. */
|
||||
simultaneousTagEditing?: boolean,
|
||||
/** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */
|
||||
reformatOnPaste?: string,
|
||||
escapeOnPaste?: boolean,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { history, redo, undo } from "@codemirror/commands";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { linkedTags } from "./linkedTags";
|
||||
import { prefsExtension } from "./prefs";
|
||||
import { angular } from "./angular";
|
||||
import { astro } from "./astro";
|
||||
import { django } from "./django";
|
||||
import { jsp } from "./jsp";
|
||||
import { vueLanguage } from "./vue";
|
||||
import { validateEditorExtras } from "@/platform/editorSettings";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
|
||||
|
||||
const extensions: Extension = [html(), linkedTags];
|
||||
|
||||
/** `|` marks carets, `[`/`]` a selection. */
|
||||
function make(text: string, extra: Extension = []) {
|
||||
return makeWith([EditorState.allowMultipleSelections.of(true), extensions, extra], text);
|
||||
}
|
||||
|
||||
/** Like `make`, with the full extension list spelled out. */
|
||||
function makeWith(extensions: Extension, text: string) {
|
||||
const ranges: ReturnType<typeof EditorSelection.cursor>[] = [];
|
||||
let doc = "";
|
||||
let anchor = -1;
|
||||
for (const ch of text) {
|
||||
if (ch === "|") ranges.push(EditorSelection.cursor(doc.length));
|
||||
else if (ch === "[") anchor = doc.length;
|
||||
else if (ch === "]") ranges.push(EditorSelection.range(anchor, doc.length));
|
||||
else doc += ch;
|
||||
}
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]),
|
||||
extensions,
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
function show(view: EditorView): string {
|
||||
const { doc, selection } = view.state;
|
||||
let out = doc.toString();
|
||||
for (const r of [...selection.ranges].sort((a, b) => b.from - a.from))
|
||||
out = r.empty
|
||||
? out.slice(0, r.head) + "|" + out.slice(r.head)
|
||||
: out.slice(0, r.from) + "[" + out.slice(r.from, r.to) + "]" + out.slice(r.to);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Types `text` at every caret / over every selection, as typing does. */
|
||||
function type(view: EditorView, text: string) {
|
||||
const { state } = view;
|
||||
view.dispatch(
|
||||
state.changeByRange((r) => ({
|
||||
changes: { from: r.from, to: r.to, insert: text },
|
||||
range: EditorSelection.cursor(r.from + text.length),
|
||||
})),
|
||||
{ userEvent: "input.type" },
|
||||
);
|
||||
}
|
||||
|
||||
/** One Backspace before every caret. */
|
||||
function backspace(view: EditorView) {
|
||||
const { state } = view;
|
||||
view.dispatch(
|
||||
state.changeByRange((r) => ({
|
||||
changes: { from: Math.max(0, r.head - 1), to: r.head },
|
||||
range: EditorSelection.cursor(Math.max(0, r.head - 1)),
|
||||
})),
|
||||
{ userEvent: "delete.backward" },
|
||||
);
|
||||
}
|
||||
|
||||
describe("linked html tags", () => {
|
||||
it("renames the closing tag while the opening one is typed", () => {
|
||||
const v = make('<div| class="container">\n Content\n</div>');
|
||||
type(v, "ision");
|
||||
expect(v.state.doc.toString()).toBe('<division class="container">\n Content\n</division>');
|
||||
// The caret stays where the user typed, not in the mirrored tag.
|
||||
expect(show(v)).toBe('<division| class="container">\n Content\n</division>');
|
||||
});
|
||||
|
||||
it("renames the opening tag while the closing one is typed", () => {
|
||||
const v = make('<div class="container">\n Content\n</div|>');
|
||||
type(v, "ision");
|
||||
expect(v.state.doc.toString()).toBe('<division class="container">\n Content\n</division>');
|
||||
});
|
||||
|
||||
it("renames a name typed at its start", () => {
|
||||
const v = make("<|div></div>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<sdiv></sdiv>");
|
||||
});
|
||||
|
||||
it("follows deletions", () => {
|
||||
const v = make("<div|></div>");
|
||||
backspace(v);
|
||||
expect(v.state.doc.toString()).toBe("<di></di>");
|
||||
backspace(v);
|
||||
expect(v.state.doc.toString()).toBe("<d></d>");
|
||||
});
|
||||
|
||||
it("follows a selection replaced with a new name", () => {
|
||||
const v = make("<[div]>x</div>");
|
||||
type(v, "article");
|
||||
expect(v.state.doc.toString()).toBe("<article>x</article>");
|
||||
expect(show(v)).toBe("<article|>x</article>");
|
||||
});
|
||||
|
||||
it("pairs the outer tags of nested same-name elements", () => {
|
||||
const v = make("<div>\n <div>inner</div>\n</div|>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs>\n <div>inner</div>\n</divs>");
|
||||
});
|
||||
|
||||
it("pairs the inner tags of nested same-name elements", () => {
|
||||
const v = make("<div>\n <div|>inner</div>\n</div>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<div>\n <divs>inner</divs>\n</div>");
|
||||
});
|
||||
|
||||
it("renames two elements with one caret each", () => {
|
||||
const v = make("<p|>one</p>\n<h2|>two</h2>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<px>one</px>\n<h2x>two</h2x>");
|
||||
});
|
||||
|
||||
it("leaves both tags alone when the caret edits both at once", () => {
|
||||
const v = make("<div|></div|>");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
});
|
||||
|
||||
it("mirrors only the caret inside a tag name", () => {
|
||||
const v = make("<p|>one\nplain| text\n</p>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<px>one\nplainx text\n</px>");
|
||||
});
|
||||
|
||||
it("skips rather than guesses with two carets in one name", () => {
|
||||
const v = make("<di|v|></div>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<dixvx></div>");
|
||||
});
|
||||
|
||||
it("does not touch other tags or attributes", () => {
|
||||
const v = make('<div class="container" |>\n <span>keep</span>\n</div>');
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe('<div class="container" x>\n <span>keep</span>\n</div>');
|
||||
});
|
||||
|
||||
it("skips void and self-closing tags", () => {
|
||||
const v = make("<p>before</p>\n<img|>\n<br>");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<p>before</p>\n<imgx>\n<br>");
|
||||
const selfClosing = make("<p>before</p>\n<div|/>");
|
||||
type(selfClosing, "x");
|
||||
expect(selfClosing.state.doc.toString()).toBe("<p>before</p>\n<divx/>");
|
||||
});
|
||||
|
||||
it("skips unclosed elements and stray close tags", () => {
|
||||
const v = make("<div|>unclosed");
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<divx>unclosed");
|
||||
const stray = make("<p>before</p>\n</div|>");
|
||||
type(stray, "x");
|
||||
expect(stray.state.doc.toString()).toBe("<p>before</p>\n</divx>");
|
||||
});
|
||||
|
||||
it("keeps a partially typed tag unmirrored", () => {
|
||||
const v = make("<di|v");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<disv");
|
||||
});
|
||||
|
||||
it("does not follow structural characters typed at the name's edge", () => {
|
||||
// `/` would make the open tag self-closing; `>` would end it: neither extends the name.
|
||||
const v = make("<div|></div>");
|
||||
type(v, "/");
|
||||
expect(v.state.doc.toString()).toBe("<div/></div>");
|
||||
const closing = make("<div>content</di|v>");
|
||||
type(closing, ">");
|
||||
expect(closing.state.doc.toString()).toBe("<div>content</di>v>");
|
||||
});
|
||||
|
||||
it("undoes the pair as one step and redoes it", () => {
|
||||
const v = make("<div|></div>", history());
|
||||
type(v, "x");
|
||||
expect(v.state.doc.toString()).toBe("<divx></divx>");
|
||||
expect(undo(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe("<div></div>");
|
||||
expect(redo(v)).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe("<divx></divx>");
|
||||
});
|
||||
|
||||
// Every mode whose outer parser is `@lezer/html`: renaming an opening tag follows into
|
||||
// the closing one (prefs.ts enables the filter for exactly these language ids).
|
||||
describe("in the html-based modes", () => {
|
||||
const modes: [string, () => Extension][] = [
|
||||
["Vue", () => vueLanguage()],
|
||||
["Angular Template", () => angular()],
|
||||
["JSP", () => jsp()],
|
||||
["Django Template", () => django()],
|
||||
["Astro", () => astro()],
|
||||
];
|
||||
|
||||
for (const [name, language] of modes) {
|
||||
it(`renames the closing tag while the opening one is typed in ${name}`, () => {
|
||||
const v = makeWith([language(), linkedTags], '<div| class="a">x</div>');
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe('<divs class="a">x</divs>');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the smart key gates the filter", () => {
|
||||
/** The real wiring: prefsExtension attaches the filter for the document's language. */
|
||||
const prefs = (language: string | undefined, smartKeys: Record<string, unknown> = {}) =>
|
||||
makeWith(
|
||||
[
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
// A parseable tree: the real editors attach the mode's parser beside prefsExtension.
|
||||
html(),
|
||||
prefsExtension({
|
||||
prefs: validateEditorExtras({ smartKeys }),
|
||||
path: "",
|
||||
softWrap: false,
|
||||
showWhitespace: false,
|
||||
hardWrapAt: 120,
|
||||
readOnly: false,
|
||||
language,
|
||||
}),
|
||||
],
|
||||
"<div|></div>",
|
||||
);
|
||||
|
||||
it("links tags for plain html", () => {
|
||||
const v = prefs("html");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
});
|
||||
|
||||
it("links tags for the html-based language ids", () => {
|
||||
for (const language of ["vue", "jsp", "django-html", "astro"]) {
|
||||
const v = prefs(language);
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></divs>");
|
||||
}
|
||||
});
|
||||
|
||||
it("stays off when the option is off", () => {
|
||||
const v = prefs("html", { simultaneousTagEditing: false });
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></div>");
|
||||
});
|
||||
|
||||
it("stays off for the token-based erb mode", () => {
|
||||
const v = prefs("erb");
|
||||
type(v, "s");
|
||||
expect(v.state.doc.toString()).toBe("<divs></div>");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Linked editing for matching HTML tags, as in IDEA's Simultaneous
|
||||
* `<tag></tag>` editing (on by default) and VS Code's Linked Editing: editing
|
||||
* the name in an opening (or closing) tag renames the matching tag of the same
|
||||
* element as you type. Done from the syntax tree — `@lezer/html`'s
|
||||
* `OpenTag`/`CloseTag` elements, shared by every HTML-based mode — so it works
|
||||
* without a language server.
|
||||
*
|
||||
* A transaction whose change lies inside one tag's `TagName` is extended with
|
||||
* the same edit at the same offset in the counterpart's name — one
|
||||
* transaction, so undo takes both back together. Without a matching tag (void
|
||||
* and self-closing tags, unclosed elements, a stray close tag) or with the two
|
||||
* names already different (malformed markup) nothing follows: the tags stay as
|
||||
* they are.
|
||||
*/
|
||||
import { EditorState, type Extension, type Transaction } from "@codemirror/state";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { SyntaxNode, Tree } from "@lezer/common";
|
||||
import { isExternalChange } from "@/editor/api";
|
||||
|
||||
/** The `TagName` a changed range edits — an insertion at either edge extends the name. */
|
||||
function tagNameAround(tree: Tree, from: number, to: number): SyntaxNode | null {
|
||||
let n = tree.resolveInner(from, -1);
|
||||
if (n.name !== "TagName") n = tree.resolveInner(from, 1);
|
||||
return n.name === "TagName" && n.from <= from && to <= n.to ? n : null;
|
||||
}
|
||||
|
||||
/** The matching tag's name of the same element, or null without one. */
|
||||
function counterpartName(name: SyntaxNode): SyntaxNode | null {
|
||||
const tag = name.parent;
|
||||
const element = tag?.parent;
|
||||
if (!tag || element?.name !== "Element") return null;
|
||||
const other = element.getChild(
|
||||
/^Open/.test(tag.name) ? tag.name.replace("Open", "Close") : tag.name.replace("Close", "Open"),
|
||||
);
|
||||
return other?.getChild("TagName") ?? null;
|
||||
}
|
||||
|
||||
/** Insertions at a name's edge must extend the name; structural characters end it. */
|
||||
const NOT_NAME_CHARS = /[\s/>"'<=]/;
|
||||
|
||||
/**
|
||||
* Editing the name of an opening (or closing) HTML tag renames its matching
|
||||
* tag, from the syntax tree — no language server. Installed while Smart Keys ›
|
||||
* Simultaneous `<tag></tag>` editing is on (the default).
|
||||
*/
|
||||
export const linkedTags: Extension = EditorState.transactionFilter.of((tr: Transaction) => {
|
||||
const state = tr.startState;
|
||||
if (!tr.docChanged || state.readOnly || isExternalChange(tr)) return tr;
|
||||
const tree = syntaxTree(state);
|
||||
const edits: { from: number; to: number; insert: string }[] = [];
|
||||
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) =>
|
||||
edits.push({ from: fromA, to: toA, insert: inserted.toString() }),
|
||||
);
|
||||
const mirrored: { from: number; to: number; insert: string }[] = [];
|
||||
const targets = new Set<number>();
|
||||
for (const edit of edits) {
|
||||
const name = tagNameAround(tree, edit.from, edit.to);
|
||||
if (!name || NOT_NAME_CHARS.test(edit.insert)) continue;
|
||||
const other = counterpartName(name);
|
||||
// Names that already differ mean malformed markup: leave the tags alone.
|
||||
if (!other || state.sliceDoc(name.from, name.to) !== state.sliceDoc(other.from, other.to)) continue;
|
||||
// Both ends edited at once (a second caret, undo of a linked edit): already in step.
|
||||
if (edits.some((e) => e.from <= other.to && other.from <= e.to)) continue;
|
||||
// Two carets in one name: their offsets would not line up; skip rather than guess.
|
||||
if (targets.has(other.from)) return tr;
|
||||
targets.add(other.from);
|
||||
mirrored.push({
|
||||
from: other.from + (edit.from - name.from),
|
||||
to: other.from + (edit.to - name.from),
|
||||
insert: edit.insert,
|
||||
});
|
||||
}
|
||||
if (!mirrored.length) return tr;
|
||||
return [
|
||||
tr,
|
||||
{
|
||||
changes: mirrored
|
||||
.map((m) => ({ from: tr.changes.mapPos(m.from, -1), to: tr.changes.mapPos(m.to, 1), insert: m.insert }))
|
||||
.sort((a, b) => a.from - b.from),
|
||||
sequential: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./c
|
||||
import { enterInBlockComment, enterInDocComment } from "./docComment";
|
||||
import { renderDocComments } from "./renderedDocs";
|
||||
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
|
||||
import { linkedTags } from "./linkedTags";
|
||||
import { stringClipboard } from "./stringClipboard";
|
||||
import { filterCopiedText } from "./clipboard";
|
||||
import { LINE_INSET } from "./lineInset";
|
||||
@@ -480,6 +481,14 @@ export const GUTTER_ICON_GROUPS = [
|
||||
{ id: "colors", gutterClass: "cm-openidea-gutter-colors" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Languages whose outer parser is `@lezer/html`, where simultaneous
|
||||
* `<tag></tag>` editing works from the syntax tree. Angular templates carry
|
||||
* the `html` backend id (the Angular mode is picked per project), `erb` is a
|
||||
* token-based mode without a syntax tree and stays out.
|
||||
*/
|
||||
const LINKED_TAG_LANGUAGES = new Set(["html", "vue", "jsp", "django-html", "astro"]);
|
||||
|
||||
export interface PrefsOptions {
|
||||
prefs: EditorExtras;
|
||||
path: string;
|
||||
@@ -605,6 +614,11 @@ export function prefsExtension(o: PrefsOptions): Extension {
|
||||
if (!k.surroundSelection) out.push(Prec.highest(noSurround));
|
||||
if (k.pairQuotes) out.push(Prec.highest(skipClosingQuote));
|
||||
if (k.tabOutOfPair) out.push(tabOut);
|
||||
// Simultaneous `<tag></tag>` editing, from the syntax tree: every mode whose outer
|
||||
// parser is `@lezer/html` — plain HTML plus the HTML-based modes (Vue SFCs, Angular
|
||||
// templates, JSP, Django and Astro; Angular templates carry the `html` backend id).
|
||||
// ERB is token-based (no syntax tree), so it stays out.
|
||||
if (!o.readOnly && k.simultaneousTagEditing && LINKED_TAG_LANGUAGES.has(o.language ?? "")) out.push(linkedTags);
|
||||
if (o.commentStyle) out.push(lineCommentStyle.of(o.commentStyle));
|
||||
|
||||
const keys = [];
|
||||
|
||||
@@ -701,6 +701,7 @@ const SMART_KEYS = editorPage({
|
||||
{ t: "check", key: `${P}.smartKeys.pairQuotes`, label: "Insert pair quote" },
|
||||
{ t: "check", key: `${P}.smartKeys.surroundSelection`, label: "Surround selection on typing quote or brace" },
|
||||
{ t: "check", key: `${P}.smartKeys.tabOutOfPair`, label: "Jump outside closing bracket/quote with Tab" },
|
||||
{ t: "check", key: `${P}.smartKeys.simultaneousTagEditing`, label: "Simultaneous <tag></tag> editing" },
|
||||
{
|
||||
t: "select",
|
||||
key: `${P}.smartKeys.reformatOnPaste`,
|
||||
|
||||
@@ -159,6 +159,7 @@ export const EDITOR_EXTRA_DEFAULTS = {
|
||||
pairQuotes: true,
|
||||
surroundSelection: true,
|
||||
tabOutOfPair: true,
|
||||
simultaneousTagEditing: true,
|
||||
reformatOnPaste: "indentEachLine",
|
||||
escapeOnPaste: true,
|
||||
smartIndent: true,
|
||||
|
||||
@@ -1436,6 +1436,7 @@
|
||||
"Role:": "Role:",
|
||||
"The user's default role when empty": "The user's default role when empty",
|
||||
"Token:": "Token:",
|
||||
"Simultaneous <tag></tag> editing": "Simultaneous <tag></tag> editing",
|
||||
"Single sign-on (web browser)": "Single sign-on (web browser)",
|
||||
"Opens your web browser to sign in with your identity provider when connecting.": "Opens your web browser to sign in with your identity provider when connecting.",
|
||||
"Execute Script": "Execute Script",
|
||||
|
||||
@@ -7840,6 +7840,7 @@
|
||||
"Role:": "角色:",
|
||||
"The user's default role when empty": "留空则使用用户的默认角色",
|
||||
"Token:": "令牌:",
|
||||
"Simultaneous <tag></tag> editing": "同时编辑 <tag></tag>",
|
||||
"Single sign-on (web browser)": "单点登录(网页浏览器)",
|
||||
"Opens your web browser to sign in with your identity provider when connecting.": "连接时打开网页浏览器,通过身份提供方登录。",
|
||||
"Execute Script": "执行脚本",
|
||||
|
||||
Reference in New Issue
Block a user