Skip to content

Commit b99ac29

Browse files
committed
Add deep-merge functionality to Jodit configuration and update tests
- Introduced `Jodit.configure()` method for deep-merging options into global defaults. - Enhanced `ConfigMerge` function to support nested merging without losing existing keys. - Updated tests to validate new configuration behavior and ensure existing functionality remains intact. - Added detailed documentation for new features in CHANGELOG.
1 parent fbbc2ff commit b99ac29

6 files changed

Lines changed: 372 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,28 @@
99
> - :house: [Internal]
1010
> - :nail_care: [Polish]
1111

12+
## 4.11.16
13+
14+
#### :rocket: New Feature
15+
16+
- Add `Jodit.configure()` static method for deep-merging partial options into global defaults without losing existing keys. Previously, overriding nested defaults like `controls` or `createAttributes` required setting each property individually. Now you can patch only the keys you need:
17+
18+
```js
19+
// Add a button without losing existing controls
20+
Jodit.configure({
21+
controls: {
22+
myButton: { icon: 'pencil', command: 'selectall' }
23+
}
24+
});
25+
26+
// Partially update createAttributes
27+
Jodit.configure({
28+
createAttributes: {
29+
div: { class: 'my-class' }
30+
}
31+
});
32+
```
33+
1234
## 4.11.15
1335

1436
#### :house: Internal

src/core/helpers/helpers.test.js

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,5 +816,255 @@ describe('Test helpers', () => {
816816
});
817817
});
818818
});
819+
820+
describe('ConfigMerge', () => {
821+
const ConfigMerge = Jodit.modules.Helpers.ConfigMerge;
822+
823+
it('Should deep-merge source into target without replacing sibling keys', () => {
824+
const target = {
825+
a: 1,
826+
b: {
827+
c: 2,
828+
d: 3
829+
}
830+
};
831+
832+
ConfigMerge(target, { b: { c: 10 } });
833+
834+
expect(target.a).eq(1);
835+
expect(target.b.c).eq(10);
836+
expect(target.b.d).eq(3);
837+
});
838+
839+
it('Should add new keys at any depth', () => {
840+
const target = {
841+
controls: {
842+
bold: { icon: 'bold' }
843+
}
844+
};
845+
846+
ConfigMerge(target, {
847+
controls: {
848+
myButton: { icon: 'pencil', command: 'selectall' }
849+
}
850+
});
851+
852+
expect(target.controls.bold.icon).eq('bold');
853+
expect(target.controls.myButton.icon).eq('pencil');
854+
expect(target.controls.myButton.command).eq('selectall');
855+
});
856+
857+
it('Should overwrite primitives', () => {
858+
const target = { language: 'en', theme: 'default' };
859+
860+
ConfigMerge(target, { language: 'de' });
861+
862+
expect(target.language).eq('de');
863+
expect(target.theme).eq('default');
864+
});
865+
866+
it('Should replace atomic values entirely', () => {
867+
const target = {
868+
controls: {
869+
fontsize: {
870+
list: [10, 12, 14, 16],
871+
icon: 'fontsize'
872+
}
873+
}
874+
};
875+
876+
ConfigMerge(target, {
877+
controls: {
878+
fontsize: {
879+
list: Jodit.atom([8, 9, 10])
880+
}
881+
}
882+
});
883+
884+
expect(target.controls.fontsize.list).deep.eq([8, 9, 10]);
885+
expect(target.controls.fontsize.icon).eq('fontsize');
886+
});
887+
888+
it('Should replace arrays (non-atom) entirely', () => {
889+
const target = { items: [1, 2, 3] };
890+
891+
ConfigMerge(target, { items: [4, 5] });
892+
893+
expect(target.items).deep.eq([4, 5]);
894+
});
895+
896+
it('Should handle multiple nested levels', () => {
897+
const target = {
898+
a: {
899+
b: {
900+
c: {
901+
d: 1,
902+
e: 2
903+
},
904+
f: 3
905+
}
906+
}
907+
};
908+
909+
ConfigMerge(target, { a: { b: { c: { d: 10 } } } });
910+
911+
expect(target.a.b.c.d).eq(10);
912+
expect(target.a.b.c.e).eq(2);
913+
expect(target.a.b.f).eq(3);
914+
});
915+
});
916+
917+
describe('Jodit.configure', () => {
918+
let origControls;
919+
920+
beforeEach(() => {
921+
origControls = { ...Jodit.defaultOptions.controls };
922+
});
923+
924+
afterEach(() => {
925+
// Restore controls to not affect other tests
926+
Object.keys(Jodit.defaultOptions.controls).forEach(key => {
927+
if (!(key in origControls)) {
928+
delete Jodit.defaultOptions.controls[key];
929+
}
930+
});
931+
Object.assign(Jodit.defaultOptions.controls, origControls);
932+
});
933+
934+
it('Should deep-merge into defaultOptions.controls without losing existing buttons', () => {
935+
const boldBefore = Jodit.defaultOptions.controls.bold;
936+
937+
Jodit.configure({
938+
controls: {
939+
testConfigureBtn: {
940+
icon: 'pencil',
941+
command: 'selectall'
942+
}
943+
}
944+
});
945+
946+
expect(Jodit.defaultOptions.controls.testConfigureBtn.icon).eq(
947+
'pencil'
948+
);
949+
expect(
950+
Jodit.defaultOptions.controls.testConfigureBtn.command
951+
).eq('selectall');
952+
expect(Jodit.defaultOptions.controls.bold).eq(boldBefore);
953+
});
954+
955+
it('Should partially update an existing control', () => {
956+
Jodit.configure({
957+
controls: {
958+
testConfigureBtn: {
959+
icon: 'pencil',
960+
command: 'selectall',
961+
group: 'custom'
962+
}
963+
}
964+
});
965+
966+
// Now update only group
967+
Jodit.configure({
968+
controls: {
969+
testConfigureBtn: {
970+
group: 'other'
971+
}
972+
}
973+
});
974+
975+
expect(Jodit.defaultOptions.controls.testConfigureBtn.icon).eq(
976+
'pencil'
977+
);
978+
expect(Jodit.defaultOptions.controls.testConfigureBtn.group).eq(
979+
'other'
980+
);
981+
});
982+
983+
it('Should deep-merge createAttributes without losing existing entries', () => {
984+
const origTable = Jodit.defaultOptions.createAttributes.table;
985+
986+
Jodit.configure({
987+
createAttributes: {
988+
div: { class: 'test-class' }
989+
}
990+
});
991+
992+
expect(Jodit.defaultOptions.createAttributes.div.class).eq(
993+
'test-class'
994+
);
995+
expect(Jodit.defaultOptions.createAttributes.table).eq(
996+
origTable
997+
);
998+
999+
// cleanup
1000+
delete Jodit.defaultOptions.createAttributes.div;
1001+
});
1002+
1003+
it('Should affect new editor instances created after configure', () => {
1004+
Jodit.configure({
1005+
controls: {
1006+
testConfigureBtn2: {
1007+
icon: 'check',
1008+
tooltip: 'Test'
1009+
}
1010+
}
1011+
});
1012+
1013+
const editor = getJodit();
1014+
expect(editor.o.controls.testConfigureBtn2.icon).eq('check');
1015+
editor.destruct();
1016+
});
1017+
1018+
it('Should apply configured createAttributes to elements created by the editor', () => {
1019+
Jodit.configure({
1020+
createAttributes: {
1021+
div: { class: 'configured-class' }
1022+
}
1023+
});
1024+
1025+
const editor = getJodit();
1026+
const div = editor.createInside.div();
1027+
expect(div.className).eq('configured-class');
1028+
1029+
// existing table default should still work
1030+
const table = editor.createInside.element('table');
1031+
expect(table.style.borderCollapse).eq('collapse');
1032+
1033+
editor.destruct();
1034+
delete Jodit.defaultOptions.createAttributes.div;
1035+
});
1036+
1037+
it('Should merge configure with per-instance options via prototype chain', () => {
1038+
Jodit.configure({
1039+
controls: {
1040+
testMergeBtn: {
1041+
icon: 'pencil',
1042+
command: 'selectall',
1043+
tooltip: 'Global tooltip'
1044+
}
1045+
}
1046+
});
1047+
1048+
// Per-instance option overrides only icon, rest comes from defaults
1049+
const editor = getJodit({
1050+
controls: {
1051+
testMergeBtn: {
1052+
icon: 'bold'
1053+
}
1054+
}
1055+
});
1056+
1057+
expect(editor.o.controls.testMergeBtn.icon).eq('bold');
1058+
expect(editor.o.controls.testMergeBtn.command).eq('selectall');
1059+
expect(editor.o.controls.testMergeBtn.tooltip).eq(
1060+
'Global tooltip'
1061+
);
1062+
1063+
// Built-in controls should still be accessible
1064+
expect(editor.o.controls.bold).not.eq(undefined);
1065+
1066+
editor.destruct();
1067+
});
1068+
});
8191069
});
8201070
});

src/core/helpers/utils/config-proto.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,39 @@ export function ConfigFlatten(obj: IDictionary): IDictionary {
123123
* console.log(JSON.stringify(plain)); // {"dialogWidth":500, "openOnDblClick": true, "editSrc": true, ...}
124124
* ```
125125
*/
126+
/**
127+
* Deep-merges `source` into `target` in-place.
128+
* Uses the same merge semantics as {@link ConfigProto}:
129+
* - Nested plain objects are merged recursively
130+
* - {@link isAtom | Atomic} values replace the target entirely
131+
* - Everything else (primitives, arrays, class instances) replaces the target value
132+
*
133+
* Designed for patching `Config.defaultOptions` without losing existing keys:
134+
*
135+
* ```js
136+
* Jodit.configure({
137+
* controls: {
138+
* someButton: { group: 'custom' }
139+
* }
140+
* });
141+
* // Only `controls.someButton` is touched — all other controls remain intact.
142+
* ```
143+
*
144+
* @see {@link ConfigProto} for the prototype-chain variant used at editor creation time
145+
*/
146+
export function ConfigMerge(target: IDictionary, source: IDictionary): void {
147+
Object.keys(source).forEach(key => {
148+
const srcVal = source[key];
149+
const tgtVal = target[key];
150+
151+
if (isPlainObject(srcVal) && isPlainObject(tgtVal) && !isAtom(srcVal)) {
152+
ConfigMerge(tgtVal, srcVal);
153+
} else {
154+
target[key] = srcVal;
155+
}
156+
});
157+
}
158+
126159
export function ConfigDeepFlatten(obj: IDictionary): IDictionary {
127160
return keys(obj, false).reduce((app, key) => {
128161
app[key] = isPlainObject(obj[key])

src/jodit.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
asArray,
5555
attr,
5656
callPromise,
57+
ConfigMerge,
5758
ConfigProto,
5859
css,
5960
error,
@@ -209,6 +210,53 @@ export class Jodit extends ViewWithToolbar implements IJodit, Dlgs {
209210
return Config.defaultOptions;
210211
}
211212

213+
/**
214+
* Deep-merges partial options into the global defaults without replacing
215+
* top-level objects. This lets you patch nested settings (e.g. a single
216+
* button inside `controls`) without losing the rest:
217+
*
218+
* ```js
219+
* // Add a custom button — all existing controls remain untouched
220+
* Jodit.configure({
221+
* controls: {
222+
* myButton: {
223+
* icon: 'pencil',
224+
* command: 'selectall'
225+
* }
226+
* }
227+
* });
228+
*
229+
* // Override only the `group` of an existing button
230+
* Jodit.configure({
231+
* controls: {
232+
* someButton: { group: 'custom' }
233+
* }
234+
* });
235+
*
236+
* // Works with any nested option
237+
* Jodit.configure({
238+
* createAttributes: {
239+
* div: { class: 'my-class' }
240+
* }
241+
* });
242+
*
243+
* // Use Jodit.atom() to replace a nested value entirely instead of merging
244+
* Jodit.configure({
245+
* controls: {
246+
* fontsize: {
247+
* list: Jodit.atom([8, 9, 10])
248+
* }
249+
* }
250+
* });
251+
* ```
252+
*
253+
* @see {@link ConfigMerge} for the merge algorithm
254+
* @see {@link ConfigProto} for per-instance prototype-based merge used at editor creation time
255+
*/
256+
static configure(options: IDictionary): void {
257+
ConfigMerge(Jodit.defaultOptions, options);
258+
}
259+
212260
static fatMode: boolean = FAT_MODE;
213261

214262
static readonly plugins: IPluginSystem = pluginSystem;

0 commit comments

Comments
 (0)