Skip to content

Commit 4c81bad

Browse files
committed
Merge branch 'IhorKaleniuk666-fix/id-conflict' into dev
2 parents 8cf995e + 6c3f0ab commit 4c81bad

File tree

3 files changed

+220
-21
lines changed

3 files changed

+220
-21
lines changed

packages/core/src/dom_components/config/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ export interface DomComponentsConfig {
6060
* work properly (eg. Web Components).
6161
*/
6262
useFrameDoc?: boolean;
63+
64+
/**
65+
* Experimental!
66+
* By default, the editor ensures unique attribute IDs for all components inside the project, no matter of the page.
67+
* With this option enabled, the editor will keep same attributes IDs for components across different pages.
68+
* This allows multiple components cross pages (eg. <footer id="footer"/>) to share the same ID (eg. to keep the same styling).
69+
*/
70+
keepAttributeIdsCrossPages?: boolean;
6371
}
6472

6573
const config: () => DomComponentsConfig = () => ({

packages/core/src/dom_components/model/Component.ts

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ import {
7272
export interface IComponent extends ExtractMethods<Component> {}
7373
export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DataWatchersOptions {}
7474
export interface ComponentSetOptions extends SetOptions, DataWatchersOptions {}
75+
export interface CheckIdOptions {
76+
keepIds?: string[];
77+
idMap?: PrevToNewIdMap;
78+
updatedIds?: Record<string, ComponentDefinitionDefined[]>;
79+
}
7580

7681
const escapeRegExp = (str: string) => {
7782
return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
@@ -316,7 +321,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
316321
};
317322
const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs);
318323
this.setAttributes(attrs);
319-
this.ccid = Component.createId(this, opt);
324+
this.ccid = Component.createId(this, opt as any);
320325
this.preInit();
321326
this.initClasses();
322327
this.initComponents();
@@ -2062,43 +2067,77 @@ export default class Component extends StyleableModel<ComponentProperties> {
20622067

20632068
static ensureInList(model: Component) {
20642069
const list = Component.getList(model);
2065-
const id = model.getId();
2070+
const propId = model.id as string | undefined;
2071+
const id = propId || model.getId();
20662072
const current = list[id];
20672073

20682074
if (!current) {
2069-
// Insert in list
20702075
list[id] = model;
20712076
} else if (current !== model) {
2072-
// Create new ID
2077+
const keepIdsCrossPages = model.em?.Components.config.keepAttributeIdsCrossPages;
2078+
const currentPage = current.page;
2079+
const modelPage = model.page;
2080+
const samePage = !!currentPage && !!modelPage && currentPage === modelPage;
20732081
const nextId = Component.getIncrementId(id, list);
2074-
model.setId(nextId);
2082+
2083+
if (samePage || !keepIdsCrossPages) {
2084+
model.setId(nextId);
2085+
} else {
2086+
model.set({ id: nextId });
2087+
}
2088+
20752089
list[nextId] = model;
20762090
}
20772091

20782092
model.components().forEach((i) => Component.ensureInList(i));
20792093
}
20802094

2081-
static createId(model: Component, opts: any = {}) {
2095+
static createId(model: Component, opts: CheckIdOptions = {}) {
20822096
const list = Component.getList(model);
2097+
const keepIdsCrossPages = model.em?.Components.config.keepAttributeIdsCrossPages;
20832098
const { idMap = {} } = opts;
2084-
let { id } = model.get('attributes')!;
2085-
let nextId;
2099+
const attrs = model.get('attributes') || {};
2100+
const attrId = attrs.id as string | undefined;
2101+
const propId = model.id as string | undefined;
2102+
const currentId = propId ?? attrId;
2103+
let nextId: string;
2104+
2105+
if (propId) {
2106+
nextId = Component.getIncrementId(propId, list, opts);
2107+
if (nextId !== propId) {
2108+
model.set({ id: nextId });
2109+
}
2110+
} else if (attrId) {
2111+
const existing = list[attrId] as Component | undefined;
20862112

2087-
if (id) {
2088-
nextId = Component.getIncrementId(id, list, opts);
2089-
model.setId(nextId);
2090-
if (id !== nextId) idMap[id] = nextId;
2113+
if (!existing || existing === model) {
2114+
nextId = attrId;
2115+
} else {
2116+
const existingPage = existing.page;
2117+
const newPage = model.page;
2118+
const samePage = !!existingPage && !!newPage && existingPage === newPage;
2119+
nextId = Component.getIncrementId(attrId, list, opts);
2120+
2121+
if (samePage || !keepIdsCrossPages) {
2122+
model.setId(nextId);
2123+
} else {
2124+
model.set({ id: nextId });
2125+
}
2126+
}
20912127
} else {
20922128
nextId = Component.getNewId(list);
20932129
}
20942130

2131+
if (!!currentId && currentId !== nextId) {
2132+
idMap[currentId] = nextId;
2133+
}
2134+
20952135
list[nextId] = model;
20962136
return nextId;
20972137
}
20982138

20992139
static getNewId(list: ObjectAny) {
21002140
const count = Object.keys(list).length;
2101-
// Testing 1000000 components with `+ 2` returns 0 collisions
21022141
const ilen = count.toString().length + 2;
21032142
const uid = (Math.random() + 1.1).toString(36).slice(-ilen);
21042143
let newId = `i${uid}`;
@@ -2126,20 +2165,14 @@ export default class Component extends StyleableModel<ComponentProperties> {
21262165
}
21272166

21282167
static getList(model: Component) {
2129-
const { em } = model;
2130-
const dm = em?.Components;
2131-
return dm?.componentsById ?? {};
2168+
return model.em?.Components?.componentsById ?? {};
21322169
}
21332170

21342171
static checkId(
21352172
components: ComponentDefinitionDefined | ComponentDefinitionDefined[],
21362173
styles: CssRuleJSON[] = [],
21372174
list: ObjectAny = {},
2138-
opts: {
2139-
keepIds?: string[];
2140-
idMap?: PrevToNewIdMap;
2141-
updatedIds?: Record<string, ComponentDefinitionDefined[]>;
2142-
} = {},
2175+
opts: CheckIdOptions = {},
21432176
) {
21442177
opts.updatedIds = opts.updatedIds || {};
21452178
const comps = isArray(components) ? components : [components];
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import Editor from '../../../src/editor';
2+
import EditorModel from '../../../src/editor/model/Editor';
3+
import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper';
4+
import { setupTestEditor } from '../../common';
5+
6+
describe('Pages with same component ids across pages', () => {
7+
let editor: Editor;
8+
let em: EditorModel;
9+
let pm: Editor['Pages'];
10+
let domc: Editor['Components'];
11+
const rootDefaultProps = {
12+
type: 'wrapper',
13+
head: { type: 'head' },
14+
docEl: { tagName: 'html' },
15+
stylable: [
16+
'background',
17+
'background-color',
18+
'background-image',
19+
'background-repeat',
20+
'background-attachment',
21+
'background-position',
22+
'background-size',
23+
],
24+
};
25+
26+
const getTitle = (wrapper: ComponentWrapper) => wrapper.components().at(0);
27+
28+
const getRootComponent = ({ idBody = 'body', idTitle = 'main-title', contentTitle = 'A' } = {}) => ({
29+
type: 'wrapper',
30+
attributes: { id: idBody },
31+
components: [
32+
{
33+
tagName: 'h1',
34+
type: 'text',
35+
attributes: { id: idTitle },
36+
components: [{ type: 'textnode', content: contentTitle }],
37+
},
38+
],
39+
});
40+
41+
beforeEach(() => {
42+
({ editor } = setupTestEditor());
43+
em = editor.getModel();
44+
pm = em.Pages;
45+
domc = em.Components;
46+
});
47+
48+
afterEach(() => {
49+
editor.destroy();
50+
});
51+
52+
test('Default behavior with pages having components with same ids are incremented', () => {
53+
editor.Pages.add({
54+
id: 'page1',
55+
frames: [{ component: getRootComponent() }],
56+
});
57+
editor.Pages.add({
58+
id: 'page2',
59+
frames: [{ component: getRootComponent({ contentTitle: 'B' }) }],
60+
});
61+
62+
const root1 = pm.get('page1')!.getMainComponent();
63+
const root2 = pm.get('page2')!.getMainComponent();
64+
65+
expect(editor.getHtml({ component: root1 })).toBe('<body id="body"><h1 id="main-title">A</h1></body>');
66+
expect(editor.getHtml({ component: root2 })).toBe('<body id="body-2"><h1 id="main-title-2">B</h1></body>');
67+
68+
expect(JSON.parse(JSON.stringify(root1))).toEqual({
69+
...rootDefaultProps,
70+
attributes: { id: 'body' },
71+
components: [
72+
{
73+
tagName: 'h1',
74+
type: 'text',
75+
attributes: { id: 'main-title' },
76+
components: [{ type: 'textnode', content: 'A' }],
77+
},
78+
],
79+
});
80+
81+
expect(JSON.parse(JSON.stringify(root2))).toEqual({
82+
...rootDefaultProps,
83+
attributes: { id: 'body-2' },
84+
components: [
85+
{
86+
tagName: 'h1',
87+
type: 'text',
88+
attributes: { id: 'main-title-2' },
89+
components: [{ type: 'textnode', content: 'B' }],
90+
},
91+
],
92+
});
93+
});
94+
95+
test('Handles pages with components having the same id across pages', () => {
96+
editor.Components.config.keepAttributeIdsCrossPages = true;
97+
editor.Pages.add({
98+
id: 'page1',
99+
frames: [{ component: getRootComponent() }],
100+
});
101+
editor.Pages.add({
102+
id: 'page2',
103+
frames: [{ component: getRootComponent({ contentTitle: 'B' }) }],
104+
});
105+
const page1 = pm.get('page1')!;
106+
const page2 = pm.get('page2')!;
107+
const root1 = page1.getMainComponent();
108+
const root2 = page2.getMainComponent();
109+
110+
expect(root1.getId()).toBe('body');
111+
expect(root2.getId()).toBe('body');
112+
113+
const title1 = getTitle(root1);
114+
const title2 = getTitle(root2);
115+
116+
// IDs should be preserved per page but stored uniquely in the shared map
117+
expect(title1.getId()).toBe('main-title');
118+
expect(title2.getId()).toBe('main-title');
119+
120+
const all = domc.allById();
121+
122+
expect(all['body']).toBe(root1);
123+
expect(all['body-2']).toBe(root2);
124+
expect(all['main-title']).toBe(title1);
125+
expect(all['main-title-2']).toBe(title2);
126+
127+
expect(editor.getHtml({ component: root1 })).toBe('<body id="body"><h1 id="main-title">A</h1></body>');
128+
expect(editor.getHtml({ component: root2 })).toBe('<body id="body"><h1 id="main-title">B</h1></body>');
129+
130+
expect(JSON.parse(JSON.stringify(root1))).toEqual({
131+
...rootDefaultProps,
132+
attributes: { id: 'body' },
133+
components: [
134+
{
135+
tagName: 'h1',
136+
type: 'text',
137+
attributes: { id: 'main-title' },
138+
components: [{ type: 'textnode', content: 'A' }],
139+
},
140+
],
141+
});
142+
143+
expect(JSON.parse(JSON.stringify(root2))).toEqual({
144+
...rootDefaultProps,
145+
id: 'body-2',
146+
attributes: { id: 'body' },
147+
components: [
148+
{
149+
id: 'main-title-2',
150+
tagName: 'h1',
151+
type: 'text',
152+
attributes: { id: 'main-title' },
153+
components: [{ type: 'textnode', content: 'B' }],
154+
},
155+
],
156+
});
157+
});
158+
});

0 commit comments

Comments
 (0)