From 03c1fb8e5613df02aba4e6e69e0ae8900d610e63 Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Tue, 25 Nov 2025 12:18:09 +0200 Subject: [PATCH 1/9] Fix ID conflict between pages in GrapesJS --- packages/core/index.html | 242 +++++++++++++----- .../src/dom_components/model/Component.ts | 123 ++++++--- .../ComponentDataCollection.ts | 16 +- 3 files changed, 282 insertions(+), 99 deletions(-) diff --git a/packages/core/index.html b/packages/core/index.html index 6dfc5ba11..4dec97398 100755 --- a/packages/core/index.html +++ b/packages/core/index.html @@ -81,80 +81,196 @@ diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 58c689047..3c3fad4b7 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -316,7 +316,7 @@ export default class Component extends StyleableModel { }; const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs); this.setAttributes(attrs); - this.ccid = Component.createId(this, opt); + this.ccid = Component.createId(this, opt as any); this.preInit(); this.initClasses(); this.initComponents(); @@ -1644,31 +1644,39 @@ export default class Component extends StyleableModel { */ toJSON(opts: ObjectAny = {}): ComponentDefinition { let obj = super.toJSON(opts, { attributes: this.getAttributes() }); - delete obj.dataResolverWatchers; - delete obj.attributes.class; - delete obj.toolbar; - delete obj.traits; - delete obj.status; - delete obj.open; // used in Layers - delete obj._undoexc; - delete obj.delegate; + delete (obj as any).dataResolverWatchers; + delete (obj as any).attributes.class; + delete (obj as any).toolbar; + delete (obj as any).traits; + delete (obj as any).status; + delete (obj as any).open; + delete (obj as any)._undoexc; + delete (obj as any).delegate; + if (this.collectionsStateMap && Object.getOwnPropertyNames(this.collectionsStateMap).length > 0) { - delete obj[keySymbol]; - delete obj[keySymbolOvrd]; - delete obj[keySymbols]; + delete (obj as any)[keySymbol]; + delete (obj as any)[keySymbolOvrd]; + delete (obj as any)[keySymbols]; } if (!opts.fromUndo) { - const symbol = obj[keySymbol]; - const symbols = obj[keySymbols]; + const symbol = (obj as any)[keySymbol]; + const symbols = (obj as any)[keySymbols]; if (symbols && isArray(symbols)) { - obj[keySymbols] = symbols.filter((i) => i).map((i) => (i.getId ? i.getId() : i)); + (obj as any)[keySymbols] = symbols.filter((i: any) => i).map((i: any) => (i.getId ? i.getId() : i)); } if (symbol && !isString(symbol)) { - obj[keySymbol] = symbol.getId(); + (obj as any)[keySymbol] = symbol.getId(); } } + // ★ Добавляем "реальный" component id, + // если он отличается от attributes.id + const attrs = this.get('attributes') || {}; + if (this.ccid && attrs.id && this.ccid !== attrs.id) { + (obj as any).id = this.ccid; + } + if (this.em.getConfig().avoidDefaults) { this.getChangedProps(obj); } @@ -2066,33 +2074,88 @@ export default class Component extends StyleableModel { const current = list[id]; if (!current) { - // Insert in list + // Первое появление такого id в трекере list[id] = model; } else if (current !== model) { - // Create new ID - const nextId = Component.getIncrementId(id, list); - model.setId(nextId); - list[nextId] = model; + const currentPage = current.page; + const modelPage = model.page; + const samePage = !!currentPage && !!modelPage && currentPage === modelPage; + + if (samePage) { + // ★ Та же страница: старое поведение + const nextId = Component.getIncrementId(id, list); + model.setId(nextId); + list[nextId] = model; + } else { + // ★ Другая страница: + // - attributes.id НЕ меняем + // - убеждаемся, что внутренний ccid уникален в трекере + const baseId = (model as any).ccid || id; + let nextId = baseId; + + while (list[nextId] && list[nextId] !== model) { + nextId = Component.getIncrementId(nextId, list); + } + + (model as any).ccid = nextId; + list[nextId] = model; + } } model.components().forEach((i) => Component.ensureInList(i)); } - static createId(model: Component, opts: any = {}) { + static createId( + model: Component, + opts: { + keepIds?: string[]; + idMap?: PrevToNewIdMap; + updatedIds?: Record; + } = {}, + ) { const list = Component.getList(model); const { idMap = {} } = opts; - let { id } = model.get('attributes')!; - let nextId; - - if (id) { - nextId = Component.getIncrementId(id, list, opts); - model.setId(nextId); - if (id !== nextId) idMap[id] = nextId; + const attrs = model.get('attributes') || {}; + const attrId = attrs.id as string | undefined; + let nextId: string; + + if (attrId) { + const existing = list[attrId] as Component | undefined; + + // Первый раз видим такой id — сохраняем как есть + if (!existing || existing === model) { + nextId = attrId; + if (!list[nextId]) { + list[nextId] = model; + } + } else { + const existingPage = existing.page; + const newPage = model.page; + const samePage = !!existingPage && !!newPage && existingPage === newPage; + + if (samePage) { + // ★ Та же страница: старое поведение — меняем attributes.id + nextId = Component.getIncrementId(attrId, list, opts); + model.setId(nextId); + if (attrId !== nextId) { + idMap[attrId] = nextId; + } + list[nextId] = model; + } else { + // ★ Другая страница: + // - attributes.id оставляем attrId + // - создаём только внутренний ID для трекинга + nextId = Component.getIncrementId(attrId, list, opts); + // не вызываем setId, чтобы не трогать attributes.id + list[nextId] = model; + } + } } else { + // Без attributes.id — как раньше nextId = Component.getNewId(list); + list[nextId] = model; } - list[nextId] = model; return nextId; } diff --git a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts index 5c2b85934..04a671300 100644 --- a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts @@ -104,12 +104,16 @@ describe('Collection component', () => { expect(cmp.getInnerHTML()).toBe(innerHTML); expect(cmp.toHTML()).toBe(`<${tagName} id="${cmp.getId()}">${innerHTML}`); expect(cmp.getEl()?.innerHTML).toBe(innerHTML); - expect(JSON.parse(JSON.stringify(cmp.toJSON()))).toEqual({ - tagName: cmp.tagName, - dataResolver: cmp.get('dataResolver'), - type: cmp.getType(), - attributes: cmp.getAttributes(), - }); + + const json = JSON.parse(JSON.stringify(cmp.toJSON())); + expect(json).toEqual( + expect.objectContaining({ + tagName, + dataResolver: cmp.get('dataResolver'), + type: cmp.getType(), + attributes: cmp.getAttributes(), + }), + ); }; const checkRecordsWithInnerCmp = () => { From f1f021173d5fbde89cb1a21403688a45603993e6 Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Tue, 25 Nov 2025 12:21:24 +0200 Subject: [PATCH 2/9] remove comments --- .../core/src/dom_components/model/Component.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 3c3fad4b7..94853a2bc 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -2074,7 +2074,6 @@ export default class Component extends StyleableModel { const current = list[id]; if (!current) { - // Первое появление такого id в трекере list[id] = model; } else if (current !== model) { const currentPage = current.page; @@ -2082,14 +2081,10 @@ export default class Component extends StyleableModel { const samePage = !!currentPage && !!modelPage && currentPage === modelPage; if (samePage) { - // ★ Та же страница: старое поведение const nextId = Component.getIncrementId(id, list); model.setId(nextId); list[nextId] = model; } else { - // ★ Другая страница: - // - attributes.id НЕ меняем - // - убеждаемся, что внутренний ccid уникален в трекере const baseId = (model as any).ccid || id; let nextId = baseId; @@ -2122,7 +2117,6 @@ export default class Component extends StyleableModel { if (attrId) { const existing = list[attrId] as Component | undefined; - // Первый раз видим такой id — сохраняем как есть if (!existing || existing === model) { nextId = attrId; if (!list[nextId]) { @@ -2134,7 +2128,6 @@ export default class Component extends StyleableModel { const samePage = !!existingPage && !!newPage && existingPage === newPage; if (samePage) { - // ★ Та же страница: старое поведение — меняем attributes.id nextId = Component.getIncrementId(attrId, list, opts); model.setId(nextId); if (attrId !== nextId) { @@ -2142,16 +2135,12 @@ export default class Component extends StyleableModel { } list[nextId] = model; } else { - // ★ Другая страница: - // - attributes.id оставляем attrId - // - создаём только внутренний ID для трекинга nextId = Component.getIncrementId(attrId, list, opts); - // не вызываем setId, чтобы не трогать attributes.id + list[nextId] = model; } } } else { - // Без attributes.id — как раньше nextId = Component.getNewId(list); list[nextId] = model; } @@ -2161,7 +2150,6 @@ export default class Component extends StyleableModel { static getNewId(list: ObjectAny) { const count = Object.keys(list).length; - // Testing 1000000 components with `+ 2` returns 0 collisions const ilen = count.toString().length + 2; const uid = (Math.random() + 1.1).toString(36).slice(-ilen); let newId = `i${uid}`; From 765663111783333ea77147f3d81906787ad7b27f Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Wed, 26 Nov 2025 13:03:10 +0200 Subject: [PATCH 3/9] update --- .../src/dom_components/model/Component.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 94853a2bc..ffbb1c006 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -1644,19 +1644,19 @@ export default class Component extends StyleableModel { */ toJSON(opts: ObjectAny = {}): ComponentDefinition { let obj = super.toJSON(opts, { attributes: this.getAttributes() }); - delete (obj as any).dataResolverWatchers; - delete (obj as any).attributes.class; - delete (obj as any).toolbar; - delete (obj as any).traits; - delete (obj as any).status; - delete (obj as any).open; - delete (obj as any)._undoexc; - delete (obj as any).delegate; + delete obj.dataResolverWatchers; + delete obj.attributes.class; + delete obj.toolbar; + delete obj.traits; + delete obj.status; + delete obj.open; + delete obj._undoexc; + delete obj.delegate; if (this.collectionsStateMap && Object.getOwnPropertyNames(this.collectionsStateMap).length > 0) { - delete (obj as any)[keySymbol]; - delete (obj as any)[keySymbolOvrd]; - delete (obj as any)[keySymbols]; + delete obj[keySymbol]; + delete obj[keySymbolOvrd]; + delete obj[keySymbols]; } if (!opts.fromUndo) { From 949ee84e5287498b0319f6b61e22151d2e14941b Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Wed, 26 Nov 2025 13:05:38 +0200 Subject: [PATCH 4/9] fix --- packages/core/index.html | 242 ++++++++++----------------------------- 1 file changed, 63 insertions(+), 179 deletions(-) diff --git a/packages/core/index.html b/packages/core/index.html index 4dec97398..6dfc5ba11 100755 --- a/packages/core/index.html +++ b/packages/core/index.html @@ -81,196 +81,80 @@ From d56ba64cad9dd7178ea8a37d7bc78c8a5041a142 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Wed, 26 Nov 2025 16:52:26 +0200 Subject: [PATCH 5/9] revert test --- .../core/src/dom_components/model/Component.ts | 4 +--- .../data_collection/ComponentDataCollection.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index ffbb1c006..fdc7c8c69 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -1670,11 +1670,9 @@ export default class Component extends StyleableModel { } } - // ★ Добавляем "реальный" component id, - // если он отличается от attributes.id const attrs = this.get('attributes') || {}; if (this.ccid && attrs.id && this.ccid !== attrs.id) { - (obj as any).id = this.ccid; + obj.id = this.ccid; } if (this.em.getConfig().avoidDefaults) { diff --git a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts index 04a671300..d9a033291 100644 --- a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts @@ -105,15 +105,12 @@ describe('Collection component', () => { expect(cmp.toHTML()).toBe(`<${tagName} id="${cmp.getId()}">${innerHTML}`); expect(cmp.getEl()?.innerHTML).toBe(innerHTML); - const json = JSON.parse(JSON.stringify(cmp.toJSON())); - expect(json).toEqual( - expect.objectContaining({ - tagName, - dataResolver: cmp.get('dataResolver'), - type: cmp.getType(), - attributes: cmp.getAttributes(), - }), - ); + expect(JSON.parse(JSON.stringify(cmp.toJSON()))).toEqual({ + tagName: cmp.tagName, + dataResolver: cmp.get('dataResolver'), + type: cmp.getType(), + attributes: cmp.getAttributes(), + }); }; const checkRecordsWithInnerCmp = () => { From 8e742f19aab3ce6428407d1e85f13da1c033f2d2 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Wed, 26 Nov 2025 16:57:56 +0200 Subject: [PATCH 6/9] update fix --- .../core/src/dom_components/model/Component.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index fdc7c8c69..61306246f 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -1649,10 +1649,9 @@ export default class Component extends StyleableModel { delete obj.toolbar; delete obj.traits; delete obj.status; - delete obj.open; + delete obj.open; // used in Layers delete obj._undoexc; delete obj.delegate; - if (this.collectionsStateMap && Object.getOwnPropertyNames(this.collectionsStateMap).length > 0) { delete obj[keySymbol]; delete obj[keySymbolOvrd]; @@ -1660,21 +1659,16 @@ export default class Component extends StyleableModel { } if (!opts.fromUndo) { - const symbol = (obj as any)[keySymbol]; - const symbols = (obj as any)[keySymbols]; + const symbol = obj[keySymbol]; + const symbols = obj[keySymbols]; if (symbols && isArray(symbols)) { - (obj as any)[keySymbols] = symbols.filter((i: any) => i).map((i: any) => (i.getId ? i.getId() : i)); + obj[keySymbols] = symbols.filter((i) => i).map((i) => (i.getId ? i.getId() : i)); } if (symbol && !isString(symbol)) { - (obj as any)[keySymbol] = symbol.getId(); + obj[keySymbol] = symbol.getId(); } } - const attrs = this.get('attributes') || {}; - if (this.ccid && attrs.id && this.ccid !== attrs.id) { - obj.id = this.ccid; - } - if (this.em.getConfig().avoidDefaults) { this.getChangedProps(obj); } From b0509c90ca26f1a312881824ec3349bf364af01d Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Mon, 1 Dec 2025 14:34:05 +0200 Subject: [PATCH 7/9] add test with page ID --- .../pages/pages-same-id-on-different-pages.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/core/test/specs/pages/pages-same-id-on-different-pages.ts diff --git a/packages/core/test/specs/pages/pages-same-id-on-different-pages.ts b/packages/core/test/specs/pages/pages-same-id-on-different-pages.ts new file mode 100644 index 000000000..328b931f7 --- /dev/null +++ b/packages/core/test/specs/pages/pages-same-id-on-different-pages.ts @@ -0,0 +1,111 @@ +import Editor from '../../../src/editor'; +import EditorModel from '../../../src/editor/model/Editor'; +import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper'; + +describe('Pages with same component ids across pages', () => { + let editor: Editor; + let em: EditorModel; + let pm: Editor['Pages']; + let domc: Editor['Components']; + const getTitle = (wrapper: ComponentWrapper) => wrapper.components().at(0)!.components().at(0)!; + + beforeAll(() => { + editor = new Editor({ + pageManager: { + pages: [ + { + id: 'p1', + frames: [ + { + component: { + type: 'wrapper', + attributes: { id: 'body' }, + components: [ + { + tagName: 'section', + components: [ + { + tagName: 'h1', + type: 'text', + attributes: { id: 'main-title' }, + components: [{ type: 'textnode', content: 'A' }], + }, + ], + }, + ], + }, + }, + ], + }, + { + id: 'p2', + frames: [ + { + component: { + type: 'wrapper', + attributes: { id: 'body' }, + components: [ + { + tagName: 'section', + components: [ + { + tagName: 'h1', + type: 'text', + attributes: { id: 'main-title' }, + components: [{ type: 'textnode', content: 'B' }], + }, + ], + }, + ], + }, + }, + ], + }, + ], + }, + }); + + em = editor.getModel(); + pm = em.Pages; + domc = em.Components; + pm.onLoad(); + }); + + afterAll(() => { + editor.destroy(); + }); + + test('Handles pages with components having the same id across pages', () => { + const pages = pm.getAll(); + expect(pages.length).toBe(2); + + const p1 = pages[0]; + const p2 = pages[1]; + + const w1 = p1.getMainComponent(); + const w2 = p2.getMainComponent(); + + expect(w1.getId()).toBe('body'); + expect(w2.getId()).toBe('body'); + + const t1 = getTitle(w1); + const t2 = getTitle(w2); + + // IDs should be preserved per page but stored uniquely in the shared map + expect(t1.getId()).toBe('main-title'); + expect(t2.getId()).toBe('main-title'); + + const all = domc.allById(); + + expect(all['body']).toBe(w1); + expect(all['body-2']).toBe(w2); + expect(all['main-title']).toBe(t1); + expect(all['main-title-2']).toBe(t2); + + const html1 = editor.getHtml({ component: w1 }); + const html2 = editor.getHtml({ component: w2 }); + + expect(html1).toContain('A'); + expect(html2).toContain('B'); + }); +}); From 8cf995e4d54dbb9d94942c86724611970d711096 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Mon, 8 Dec 2025 13:59:36 +0400 Subject: [PATCH 8/9] Update resolveCollectionVariable (#6670) --- .../src/data_sources/model/DataVariable.ts | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/packages/core/src/data_sources/model/DataVariable.ts b/packages/core/src/data_sources/model/DataVariable.ts index 0f9bcf191..f8adf61d2 100644 --- a/packages/core/src/data_sources/model/DataVariable.ts +++ b/packages/core/src/data_sources/model/DataVariable.ts @@ -150,41 +150,13 @@ export default class DataVariable extends Model { ctx: DataVariableOptions, ) { const { collectionId = '', variableType, path, defaultValue = '' } = params; - const { em, collectionsStateMap } = ctx; + const { collectionsStateMap, em } = ctx; + const collectionItemState = collectionsStateMap?.[collectionId] as DataCollectionState | undefined; - if (!collectionsStateMap) return defaultValue; + if (!collectionItemState || !variableType) return defaultValue; - const collectionItem = collectionsStateMap[collectionId]; - if (!collectionItem) return defaultValue; - - if (!variableType) { - em.logError(`Missing collection variable type for collection: ${collectionId}`); - return defaultValue; - } - - if (variableType === 'currentItem') { - return DataVariable.resolveCurrentItem(collectionItem as DataCollectionState, path) ?? defaultValue; - } - - const state = collectionItem as DataCollectionState; - return state[variableType] ?? defaultValue; - } - - private static resolveCurrentItem(collectionItem: DataCollectionState, path: string | undefined) { - const currentItem = collectionItem.currentItem; - if (!currentItem) { - return; - } - - if (currentItem.type === DataVariableType) { - const resolvedPath = currentItem.path ? `${currentItem.path}.${path}` : path; - return { type: DataVariableType, path: resolvedPath }; - } - - if (path && !(currentItem as any)[path]) { - return; - } - - return path ? (currentItem as any)[path] : currentItem; + return em.DataSources.getValue(`${variableType}${path ? `.${path}` : ''}`, defaultValue, { + context: collectionItemState, + }); } } From 6c3f0ab0f42382f18f9f41172b1616d8c8475e75 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Tue, 9 Dec 2025 12:39:02 +0400 Subject: [PATCH 9/9] Add keepAttributeIdsCrossPages option --- .../core/src/dom_components/config/config.ts | 8 + .../src/dom_components/model/Component.ts | 76 +++---- .../ComponentDataCollection.ts | 1 - .../pages/pages-same-id-on-different-pages.ts | 205 +++++++++++------- 4 files changed, 167 insertions(+), 123 deletions(-) diff --git a/packages/core/src/dom_components/config/config.ts b/packages/core/src/dom_components/config/config.ts index a7bff2d54..405df657e 100644 --- a/packages/core/src/dom_components/config/config.ts +++ b/packages/core/src/dom_components/config/config.ts @@ -60,6 +60,14 @@ export interface DomComponentsConfig { * work properly (eg. Web Components). */ useFrameDoc?: boolean; + + /** + * Experimental! + * By default, the editor ensures unique attribute IDs for all components inside the project, no matter of the page. + * With this option enabled, the editor will keep same attributes IDs for components across different pages. + * This allows multiple components cross pages (eg.