@obelism/content 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -10
- package/dist/bridge.d.mts +15 -11
- package/dist/bridge.d.mts.map +1 -1
- package/dist/bridge.d.ts +15 -11
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.global.js +10 -12
- package/dist/bridge.js +341 -326
- package/dist/bridge.mjs +341 -326
- package/dist/site.js +5 -0
- package/dist/site.mjs +5 -0
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -231,6 +231,12 @@ const INERT_METHODS = {
|
|
|
231
231
|
createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
|
|
232
232
|
view: onlyWhileEditingSite,
|
|
233
233
|
applyJson: onlyWhileEditingSite,
|
|
234
|
+
openForm: (_container, options = {})=>{
|
|
235
|
+
queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
|
|
236
|
+
return {
|
|
237
|
+
close: ()=>{}
|
|
238
|
+
};
|
|
239
|
+
},
|
|
234
240
|
pages: onlyWhileEditingSite,
|
|
235
241
|
openPage: ()=>{},
|
|
236
242
|
undo: ()=>{},
|
|
@@ -497,6 +503,8 @@ const DEFAULT_PROMPT_LABELS = {
|
|
|
497
503
|
send: 'Apply',
|
|
498
504
|
undo: 'Undo',
|
|
499
505
|
close: 'Close',
|
|
506
|
+
minimize: 'Minimize, to see the page',
|
|
507
|
+
expand: 'Expand',
|
|
500
508
|
scopeEntry: 'Whole page',
|
|
501
509
|
scopeBlock: 'Selected block',
|
|
502
510
|
clearBlock: 'Edit the whole page instead',
|
|
@@ -520,16 +528,9 @@ const DEFAULT_PROMPT_LABELS = {
|
|
|
520
528
|
created: 'Opening the new page…',
|
|
521
529
|
modes: 'Edit with',
|
|
522
530
|
modePrompt: 'Prompt',
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
jsonReload: 'Reload the JSON',
|
|
527
|
-
jsonKept: 'Kept your unapplied JSON. Reload to see the current content.',
|
|
528
|
-
jsonInvalidAt: 'Not valid JSON at line {line}, column {column}',
|
|
529
|
-
jsonInvalid: 'Not valid JSON',
|
|
530
|
-
jsonNotObject: 'The JSON has to be an object of fields, like { "title": … }',
|
|
531
|
-
applied: 'Applied',
|
|
532
|
-
unchanged: 'Nothing changed',
|
|
531
|
+
form: 'Form',
|
|
532
|
+
formTitle: 'The content form',
|
|
533
|
+
formLoading: 'Opening the form…',
|
|
533
534
|
pages: 'Pages',
|
|
534
535
|
pagesSearch: 'Find a page',
|
|
535
536
|
pagesLoading: 'Loading pages…',
|
|
@@ -545,7 +546,8 @@ const ICONS = {
|
|
|
545
546
|
block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
|
|
546
547
|
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
|
|
547
548
|
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
548
|
-
|
|
549
|
+
minimize: '<path d="m6 9 6 6 6-6"/>',
|
|
550
|
+
expand: '<path d="m18 15-6-6-6 6"/>',
|
|
549
551
|
chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
|
550
552
|
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
551
553
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
@@ -650,13 +652,13 @@ textarea {
|
|
|
650
652
|
font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
|
|
651
653
|
}
|
|
652
654
|
textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
653
|
-
/*
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
font-size: 13px; line-height: 1.55;
|
|
655
|
+
/* Form: the CMS's entry form, edited live. */
|
|
656
|
+
.form { padding: 12px 12px 0; }
|
|
657
|
+
.form iframe {
|
|
658
|
+
display: block; width: 100%; height: min(560px, 60vh); border: none; border-radius: 12px;
|
|
659
|
+
background: rgb(var(--theme-background-actions-rgb) / .45);
|
|
659
660
|
}
|
|
661
|
+
.form.loading iframe { visibility: hidden; }
|
|
660
662
|
/* New page: where it goes, above what it is for. */
|
|
661
663
|
.path {
|
|
662
664
|
display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
|
|
@@ -700,7 +702,7 @@ button.chip:disabled { opacity: .5; }
|
|
|
700
702
|
.icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
|
|
701
703
|
.icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
|
|
702
704
|
.icon-button.primary:disabled { opacity: .35; }
|
|
703
|
-
/* Prompt or
|
|
705
|
+
/* Prompt or Form: two small pills in one track. */
|
|
704
706
|
.modes {
|
|
705
707
|
display: inline-flex; flex: none; gap: 2px; padding: 2px; border-radius: var(--border-radius-pill);
|
|
706
708
|
border: 1px solid rgb(var(--theme-border-rgb) / .2); background: rgb(var(--theme-background-actions-rgb) / .6);
|
|
@@ -783,101 +785,6 @@ const icon = (doc, name)=>{
|
|
|
783
785
|
span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name]}</svg>`;
|
|
784
786
|
return span;
|
|
785
787
|
};
|
|
786
|
-
// Where `text` stops being JSON: the offset of the first character that
|
|
787
|
-
// doesn't fit, or -1 when it is JSON. JSON.parse's errors don't all say
|
|
788
|
-
// where (V8's "Unexpected token" doesn't, Safari's never do).
|
|
789
|
-
const jsonErrorAt = (text)=>{
|
|
790
|
-
let i = 0;
|
|
791
|
-
const fail = ()=>{
|
|
792
|
-
throw i;
|
|
793
|
-
};
|
|
794
|
-
const space = ()=>{
|
|
795
|
-
while(i < text.length && ' \t\n\r'.includes(text[i]))i++;
|
|
796
|
-
};
|
|
797
|
-
const string = ()=>{
|
|
798
|
-
i++;
|
|
799
|
-
while(i < text.length){
|
|
800
|
-
const char = text[i];
|
|
801
|
-
if (char === '"') {
|
|
802
|
-
i++;
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
if (char === '\\') {
|
|
806
|
-
const next = text[i + 1] ?? '';
|
|
807
|
-
if (next === 'u' && /^[\da-f]{4}$/i.test(text.slice(i + 2, i + 6))) {
|
|
808
|
-
i += 6;
|
|
809
|
-
} else if (next !== '' && '"\\/bfnrt'.includes(next)) {
|
|
810
|
-
i += 2;
|
|
811
|
-
} else {
|
|
812
|
-
i++;
|
|
813
|
-
fail();
|
|
814
|
-
}
|
|
815
|
-
} else if (char < ' ') {
|
|
816
|
-
fail();
|
|
817
|
-
} else {
|
|
818
|
-
i++;
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
fail();
|
|
822
|
-
};
|
|
823
|
-
const items = (close, item)=>{
|
|
824
|
-
i++;
|
|
825
|
-
space();
|
|
826
|
-
if (text[i] === close) {
|
|
827
|
-
i++;
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
for(;;){
|
|
831
|
-
item();
|
|
832
|
-
space();
|
|
833
|
-
if (text[i] === close) {
|
|
834
|
-
i++;
|
|
835
|
-
return;
|
|
836
|
-
}
|
|
837
|
-
if (text[i] !== ',') fail();
|
|
838
|
-
i++;
|
|
839
|
-
}
|
|
840
|
-
};
|
|
841
|
-
const value = ()=>{
|
|
842
|
-
space();
|
|
843
|
-
const char = text[i] ?? '';
|
|
844
|
-
if (char === '{') {
|
|
845
|
-
return items('}', ()=>{
|
|
846
|
-
space();
|
|
847
|
-
if (text[i] !== '"') fail();
|
|
848
|
-
string();
|
|
849
|
-
space();
|
|
850
|
-
if (text[i] !== ':') fail();
|
|
851
|
-
i++;
|
|
852
|
-
value();
|
|
853
|
-
});
|
|
854
|
-
}
|
|
855
|
-
if (char === '[') return items(']', value);
|
|
856
|
-
if (char === '"') return string();
|
|
857
|
-
for (const word of [
|
|
858
|
-
'true',
|
|
859
|
-
'false',
|
|
860
|
-
'null'
|
|
861
|
-
]){
|
|
862
|
-
if (text.startsWith(word, i)) {
|
|
863
|
-
i += word.length;
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
const number = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(text.slice(i));
|
|
868
|
-
if (!number) return fail();
|
|
869
|
-
i += number[0].length;
|
|
870
|
-
};
|
|
871
|
-
try {
|
|
872
|
-
value();
|
|
873
|
-
space();
|
|
874
|
-
if (i < text.length) fail();
|
|
875
|
-
return -1;
|
|
876
|
-
} catch (at) {
|
|
877
|
-
if (typeof at !== 'number') throw at;
|
|
878
|
-
return Math.min(at, text.length);
|
|
879
|
-
}
|
|
880
|
-
};
|
|
881
788
|
// Mounts the box on the page; returns a function that removes it.
|
|
882
789
|
const mountPromptBox = (doc, bridge, options = {})=>{
|
|
883
790
|
const labels = {
|
|
@@ -921,14 +828,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
921
828
|
rows: 1
|
|
922
829
|
});
|
|
923
830
|
input.setAttribute('aria-label', labels.open);
|
|
924
|
-
//
|
|
925
|
-
const
|
|
926
|
-
className: '
|
|
927
|
-
spellcheck: false,
|
|
928
|
-
autocomplete: 'off'
|
|
831
|
+
// Form mode (on the site): the CMS's form, mounted while shown.
|
|
832
|
+
const formBox = element(doc, 'div', {
|
|
833
|
+
className: 'form'
|
|
929
834
|
});
|
|
930
|
-
code.setAttribute('aria-label', labels.json);
|
|
931
|
-
code.setAttribute('autocapitalize', 'off');
|
|
932
835
|
// What the prompt acts on: the whole page, or the selected block with a
|
|
933
836
|
// way back to the page.
|
|
934
837
|
const pageIcon = icon(doc, 'page');
|
|
@@ -947,7 +850,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
947
850
|
className: 'action chip',
|
|
948
851
|
type: 'button'
|
|
949
852
|
}, icon(doc, 'undo'), labels.undo);
|
|
950
|
-
// Prompt or
|
|
853
|
+
// Prompt or Form (on the site).
|
|
951
854
|
const modeButton = (text)=>{
|
|
952
855
|
const button = element(doc, 'button', {
|
|
953
856
|
type: 'button',
|
|
@@ -957,18 +860,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
957
860
|
return button;
|
|
958
861
|
};
|
|
959
862
|
const promptMode = modeButton(labels.modePrompt);
|
|
960
|
-
const
|
|
863
|
+
const formMode = modeButton(labels.form);
|
|
961
864
|
const modes = element(doc, 'span', {
|
|
962
865
|
className: 'modes'
|
|
963
|
-
}, promptMode,
|
|
866
|
+
}, promptMode, formMode);
|
|
964
867
|
modes.setAttribute('role', 'group');
|
|
965
868
|
modes.setAttribute('aria-label', labels.modes);
|
|
966
|
-
const reload = element(doc, 'button', {
|
|
967
|
-
className: 'icon-button',
|
|
968
|
-
type: 'button',
|
|
969
|
-
title: labels.jsonReload
|
|
970
|
-
}, icon(doc, 'reload'));
|
|
971
|
-
reload.setAttribute('aria-label', labels.jsonReload);
|
|
972
869
|
// New page (on the site, for people who may add pages): the scope becomes
|
|
973
870
|
// the new page, with a way back, and a path field shows above the prompt.
|
|
974
871
|
const newPage = element(doc, 'button', {
|
|
@@ -1003,6 +900,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1003
900
|
title: labels.close
|
|
1004
901
|
}, icon(doc, 'close'));
|
|
1005
902
|
close.setAttribute('aria-label', labels.close);
|
|
903
|
+
// Minimized, only the toolbar and footer show, so a short page stays in
|
|
904
|
+
// view; the form stays mounted (and its edits) meanwhile.
|
|
905
|
+
const minimizeIcon = icon(doc, 'minimize');
|
|
906
|
+
const expandIcon = icon(doc, 'expand');
|
|
907
|
+
const minimize = element(doc, 'button', {
|
|
908
|
+
className: 'icon-button',
|
|
909
|
+
type: 'button'
|
|
910
|
+
}, minimizeIcon, expandIcon);
|
|
1006
911
|
const send = element(doc, 'button', {
|
|
1007
912
|
className: 'action primary icon-button',
|
|
1008
913
|
type: 'submit'
|
|
@@ -1011,13 +916,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1011
916
|
textContent: labels.send
|
|
1012
917
|
}), icon(doc, 'send'));
|
|
1013
918
|
send.title = labels.send;
|
|
919
|
+
const divider = element(doc, 'span', {
|
|
920
|
+
className: 'divider'
|
|
921
|
+
});
|
|
1014
922
|
const toolbar = element(doc, 'div', {
|
|
1015
923
|
className: 'toolbar'
|
|
1016
924
|
}, scope, newScope, modes, undo, newPage, element(doc, 'span', {
|
|
1017
925
|
className: 'spacer'
|
|
1018
|
-
}),
|
|
1019
|
-
className: 'divider'
|
|
1020
|
-
}), send);
|
|
926
|
+
}), minimize, close, divider, send);
|
|
1021
927
|
// The pages (on the site): a chip naming this page, opening a search
|
|
1022
928
|
// and a list of every page in place of the prompt.
|
|
1023
929
|
const pageChipText = element(doc, 'span', {
|
|
@@ -1085,7 +991,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1085
991
|
}), signOut, save, publish);
|
|
1086
992
|
const panel = element(doc, 'form', {
|
|
1087
993
|
className: 'panel'
|
|
1088
|
-
}, status, pathRow, input,
|
|
994
|
+
}, status, pathRow, input, formBox, toolbar, pagesSection, footer);
|
|
1089
995
|
const root = element(doc, 'div', {
|
|
1090
996
|
className: 'root'
|
|
1091
997
|
}, element(doc, 'div', {
|
|
@@ -1095,17 +1001,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1095
1001
|
textContent: CSS
|
|
1096
1002
|
}), root);
|
|
1097
1003
|
let open = false;
|
|
1004
|
+
let minimized = false;
|
|
1098
1005
|
let creating = false;
|
|
1099
1006
|
let message = null;
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
|
|
1103
|
-
let
|
|
1104
|
-
let
|
|
1105
|
-
let viewUid = null;
|
|
1106
|
-
let seenUid;
|
|
1107
|
-
let loadingView = false;
|
|
1108
|
-
let viewRequest = 0;
|
|
1007
|
+
// Form mode, and the form while mounted: a fresh one (and one-time
|
|
1008
|
+
// address) every time it shows.
|
|
1009
|
+
let form = false;
|
|
1010
|
+
let formHandle = null;
|
|
1011
|
+
let formLoading = false;
|
|
1109
1012
|
// The pages, fetched once the panel opens and kept; `active` is the
|
|
1110
1013
|
// option arrow keys are on.
|
|
1111
1014
|
let picking = false;
|
|
@@ -1132,40 +1035,50 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1132
1035
|
const onlyNew = pageless && canCreate;
|
|
1133
1036
|
if (!canCreate) creating = false;
|
|
1134
1037
|
if (onlyNew) creating = true;
|
|
1135
|
-
if (creating || pageless)
|
|
1038
|
+
if (creating || pageless || !site) form = false;
|
|
1039
|
+
// The form is there only while shown in the open panel.
|
|
1040
|
+
const showForm = open && available && form;
|
|
1041
|
+
if (showForm && !formHandle) mountForm();
|
|
1042
|
+
else if (!showForm && formHandle) unmountForm();
|
|
1136
1043
|
pillText.textContent = onlyNew ? labels.newPage : labels.open;
|
|
1137
1044
|
cancelNew.hidden = onlyNew;
|
|
1138
1045
|
scope.hidden = creating;
|
|
1139
1046
|
newScope.hidden = !creating;
|
|
1140
|
-
newPage.hidden = creating ||
|
|
1047
|
+
newPage.hidden = creating || form || !canCreate;
|
|
1141
1048
|
newPage.disabled = busy;
|
|
1142
|
-
|
|
1049
|
+
if (minimized) picking = false;
|
|
1050
|
+
pathRow.hidden = !creating || picking || minimized;
|
|
1143
1051
|
pathInput.disabled = busy;
|
|
1144
|
-
input.hidden =
|
|
1145
|
-
|
|
1052
|
+
input.hidden = form || picking || minimized;
|
|
1053
|
+
formBox.hidden = !form || picking || minimized;
|
|
1054
|
+
formBox.classList.toggle('loading', formLoading);
|
|
1146
1055
|
toolbar.hidden = picking;
|
|
1147
1056
|
pagesSection.hidden = !picking;
|
|
1148
1057
|
input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
|
|
1149
|
-
|
|
1150
|
-
const scopeUid = json ? viewUid : state.selectedUid;
|
|
1151
|
-
scopeText.textContent = scopeUid ? labels.scopeBlock : labels.scopeEntry;
|
|
1058
|
+
scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
|
|
1152
1059
|
clearBlock.hidden = !state.selectedUid;
|
|
1153
|
-
pageIcon.hidden = !!
|
|
1154
|
-
blockIcon.hidden = !
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1060
|
+
pageIcon.hidden = !!state.selectedUid;
|
|
1061
|
+
blockIcon.hidden = !state.selectedUid;
|
|
1062
|
+
// A CMS from before the form doesn't offer it.
|
|
1063
|
+
modes.hidden = !site || creating || pageless || !state.features.includes('form');
|
|
1064
|
+
promptMode.setAttribute('aria-pressed', String(!form));
|
|
1065
|
+
formMode.setAttribute('aria-pressed', String(form));
|
|
1158
1066
|
modes.querySelectorAll('button').forEach((button)=>{
|
|
1159
|
-
button.disabled = busy
|
|
1067
|
+
button.disabled = busy;
|
|
1160
1068
|
});
|
|
1161
|
-
reload.hidden = !json;
|
|
1162
|
-
reload.disabled = busy || loadingView;
|
|
1163
1069
|
undo.hidden = creating || !state.canUndo;
|
|
1164
|
-
undo.disabled = busy
|
|
1165
|
-
|
|
1166
|
-
send.
|
|
1070
|
+
undo.disabled = busy;
|
|
1071
|
+
// The form applies edits as they are made.
|
|
1072
|
+
send.hidden = form || minimized;
|
|
1073
|
+
divider.hidden = form || minimized;
|
|
1074
|
+
const toggleLabel = minimized ? labels.expand : labels.minimize;
|
|
1075
|
+
minimize.title = toggleLabel;
|
|
1076
|
+
minimize.setAttribute('aria-label', toggleLabel);
|
|
1077
|
+
minimize.setAttribute('aria-expanded', String(!minimized));
|
|
1078
|
+
minimizeIcon.hidden = minimized;
|
|
1079
|
+
expandIcon.hidden = !minimized;
|
|
1080
|
+
send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
|
|
1167
1081
|
input.disabled = busy;
|
|
1168
|
-
code.disabled = busy || loadingView;
|
|
1169
1082
|
save.hidden = !state.features.includes('save') || pageless;
|
|
1170
1083
|
save.disabled = busy || !state.unsaved;
|
|
1171
1084
|
save.textContent = state.saving ? labels.saving : labels.save;
|
|
@@ -1178,141 +1091,57 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1178
1091
|
pageChip.hidden = !site;
|
|
1179
1092
|
pageChip.setAttribute('aria-expanded', String(picking));
|
|
1180
1093
|
pageChipText.textContent = pages?.find(({ entryId })=>entryId === bridge.entryId)?.name ?? labels.pages;
|
|
1181
|
-
const
|
|
1182
|
-
const
|
|
1094
|
+
const loadingForm = form && formLoading && !picking;
|
|
1095
|
+
const working = state.prompting || loadingForm;
|
|
1096
|
+
const note = state.prompting ? creating ? labels.creating : labels.working : loadingForm ? labels.formLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
|
|
1183
1097
|
status.textContent = note;
|
|
1184
|
-
status.hidden = !note;
|
|
1185
|
-
status.className = `status${working ? ' working' : ''}${!busy && !
|
|
1186
|
-
// A new selection while editing JSON (or a reload asked for): its
|
|
1187
|
-
// JSON, after this render.
|
|
1188
|
-
if (open && json && !picking && state.selectedUid !== seenUid) {
|
|
1189
|
-
seenUid = state.selectedUid;
|
|
1190
|
-
queueMicrotask(()=>void loadView(false));
|
|
1191
|
-
}
|
|
1098
|
+
status.hidden = !note || minimized;
|
|
1099
|
+
status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
|
|
1192
1100
|
};
|
|
1193
1101
|
const refresh = ()=>render(bridge.getState());
|
|
1194
1102
|
const grow = ()=>{
|
|
1195
1103
|
input.style.height = 'auto';
|
|
1196
1104
|
input.style.height = `${input.scrollHeight}px`;
|
|
1197
1105
|
};
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
const
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
});
|
|
1219
|
-
if (request !== viewRequest) return false;
|
|
1220
|
-
loaded = `${JSON.stringify(view, null, 2)}\n`;
|
|
1221
|
-
code.value = loaded;
|
|
1222
|
-
code.scrollTop = 0;
|
|
1223
|
-
viewUid = uid;
|
|
1224
|
-
return true;
|
|
1225
|
-
} catch (error) {
|
|
1226
|
-
if (request === viewRequest) message = failure(error);
|
|
1227
|
-
return false;
|
|
1228
|
-
} finally{
|
|
1229
|
-
if (request === viewRequest) {
|
|
1230
|
-
loadingView = false;
|
|
1106
|
+
// Callbacks come later (the address is fetched first), never during a
|
|
1107
|
+
// render.
|
|
1108
|
+
const mountForm = ()=>{
|
|
1109
|
+
formLoading = true;
|
|
1110
|
+
formBox.classList.add('loading');
|
|
1111
|
+
const handle = bridge.openForm(formBox, {
|
|
1112
|
+
title: labels.formTitle,
|
|
1113
|
+
onReady: ()=>{
|
|
1114
|
+
if (formHandle !== handle) return;
|
|
1115
|
+
formLoading = false;
|
|
1116
|
+
message = null;
|
|
1117
|
+
refresh();
|
|
1118
|
+
},
|
|
1119
|
+
onError: (text)=>{
|
|
1120
|
+
if (formHandle !== handle) return;
|
|
1121
|
+
formLoading = false;
|
|
1122
|
+
message = {
|
|
1123
|
+
text,
|
|
1124
|
+
error: true
|
|
1125
|
+
};
|
|
1231
1126
|
refresh();
|
|
1232
1127
|
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
// Loads the JSON again once rendered, keeping edited text.
|
|
1236
|
-
const requestView = ()=>{
|
|
1237
|
-
seenUid = undefined;
|
|
1238
|
-
refresh();
|
|
1239
|
-
};
|
|
1240
|
-
const setJson = (next)=>{
|
|
1241
|
-
if (json === next) return;
|
|
1242
|
-
json = next;
|
|
1243
|
-
message = null;
|
|
1244
|
-
requestView();
|
|
1245
|
-
(json ? code : input).focus();
|
|
1128
|
+
});
|
|
1129
|
+
formHandle = handle;
|
|
1246
1130
|
};
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
1252
|
-
return {
|
|
1253
|
-
text: `${labels.jsonInvalid}: ${reason}`
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
|
-
const before = text.slice(0, offset);
|
|
1257
|
-
const line = before.split('\n').length;
|
|
1258
|
-
const column = offset - before.lastIndexOf('\n');
|
|
1259
|
-
return {
|
|
1260
|
-
text: labels.jsonInvalidAt.replace('{line}', String(line)).replace('{column}', String(column)),
|
|
1261
|
-
offset
|
|
1262
|
-
};
|
|
1131
|
+
const unmountForm = ()=>{
|
|
1132
|
+
formHandle?.close();
|
|
1133
|
+
formHandle = null;
|
|
1134
|
+
formLoading = false;
|
|
1263
1135
|
};
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
let changes;
|
|
1268
|
-
try {
|
|
1269
|
-
changes = JSON.parse(text);
|
|
1270
|
-
} catch (error) {
|
|
1271
|
-
const problem = parseProblem(text, error);
|
|
1272
|
-
message = {
|
|
1273
|
-
text: problem.text,
|
|
1274
|
-
error: true
|
|
1275
|
-
};
|
|
1276
|
-
refresh();
|
|
1277
|
-
code.focus();
|
|
1278
|
-
if (problem.offset !== undefined) {
|
|
1279
|
-
code.setSelectionRange(problem.offset, problem.offset);
|
|
1280
|
-
}
|
|
1281
|
-
return;
|
|
1282
|
-
}
|
|
1283
|
-
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
|
|
1284
|
-
message = {
|
|
1285
|
-
text: labels.jsonNotObject,
|
|
1286
|
-
error: true
|
|
1287
|
-
};
|
|
1288
|
-
return refresh();
|
|
1289
|
-
}
|
|
1136
|
+
const setForm = (next)=>{
|
|
1137
|
+
if (form === next) return;
|
|
1138
|
+
form = next;
|
|
1290
1139
|
message = null;
|
|
1291
|
-
try {
|
|
1292
|
-
const { changed, view } = await bridge.applyJson(changes, {
|
|
1293
|
-
uid: viewUid
|
|
1294
|
-
});
|
|
1295
|
-
// What the CMS made of it, from now on what's edited: in the
|
|
1296
|
-
// answer, or asked for (a CMS from before `view` in it).
|
|
1297
|
-
if (view) {
|
|
1298
|
-
++viewRequest;
|
|
1299
|
-
loaded = `${JSON.stringify(view, null, 2)}\n`;
|
|
1300
|
-
code.value = loaded;
|
|
1301
|
-
}
|
|
1302
|
-
if (view || await loadView(true)) {
|
|
1303
|
-
message = {
|
|
1304
|
-
text: changed.length > 0 ? labels.applied : labels.unchanged,
|
|
1305
|
-
error: false
|
|
1306
|
-
};
|
|
1307
|
-
}
|
|
1308
|
-
} catch (error) {
|
|
1309
|
-
message = failure(error);
|
|
1310
|
-
}
|
|
1311
1140
|
refresh();
|
|
1312
|
-
|
|
1141
|
+
if (!form) input.focus();
|
|
1313
1142
|
};
|
|
1314
1143
|
const submit = async ()=>{
|
|
1315
|
-
if (
|
|
1144
|
+
if (form) return;
|
|
1316
1145
|
const prompt = input.value.trim();
|
|
1317
1146
|
if (!prompt || bridge.getState().prompting) return;
|
|
1318
1147
|
message = null;
|
|
@@ -1429,11 +1258,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1429
1258
|
};
|
|
1430
1259
|
const setOpen = (next)=>{
|
|
1431
1260
|
open = next;
|
|
1432
|
-
if (!open)
|
|
1261
|
+
if (!open) {
|
|
1262
|
+
picking = false;
|
|
1263
|
+
minimized = false;
|
|
1264
|
+
}
|
|
1433
1265
|
refresh();
|
|
1434
1266
|
if (site) bridge.setSelecting(open && !creating);
|
|
1435
1267
|
if (open && site) void fetchPages();
|
|
1436
|
-
if (open && json) requestView();
|
|
1437
1268
|
};
|
|
1438
1269
|
const setCreating = (next)=>{
|
|
1439
1270
|
creating = next;
|
|
@@ -1458,9 +1289,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1458
1289
|
};
|
|
1459
1290
|
pill.addEventListener('click', ()=>{
|
|
1460
1291
|
setOpen(true);
|
|
1461
|
-
(creating ? pathInput :
|
|
1292
|
+
if (!form) (creating ? pathInput : input).focus();
|
|
1462
1293
|
});
|
|
1463
1294
|
close.addEventListener('click', ()=>setOpen(false));
|
|
1295
|
+
minimize.addEventListener('click', ()=>{
|
|
1296
|
+
minimized = !minimized;
|
|
1297
|
+
refresh();
|
|
1298
|
+
if (!minimized) (form ? minimize : input).focus();
|
|
1299
|
+
});
|
|
1464
1300
|
save.addEventListener('click', ()=>void store('save'));
|
|
1465
1301
|
publish.addEventListener('click', ()=>void store('publish'));
|
|
1466
1302
|
signOut.addEventListener('click', ()=>bridge.signOut());
|
|
@@ -1468,12 +1304,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1468
1304
|
clearBlock.addEventListener('click', ()=>bridge.select(null));
|
|
1469
1305
|
newPage.addEventListener('click', ()=>setCreating(true));
|
|
1470
1306
|
cancelNew.addEventListener('click', ()=>setCreating(false));
|
|
1471
|
-
promptMode.addEventListener('click', ()=>
|
|
1472
|
-
|
|
1473
|
-
reload.addEventListener('click', ()=>{
|
|
1474
|
-
void loadView(true);
|
|
1475
|
-
code.focus();
|
|
1476
|
-
});
|
|
1307
|
+
promptMode.addEventListener('click', ()=>setForm(false));
|
|
1308
|
+
formMode.addEventListener('click', ()=>setForm(true));
|
|
1477
1309
|
pageChip.addEventListener('click', ()=>setPicking(!picking));
|
|
1478
1310
|
pathInput.addEventListener('input', refresh);
|
|
1479
1311
|
pathInput.addEventListener('keydown', (event)=>{
|
|
@@ -1507,7 +1339,6 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1507
1339
|
undo.addEventListener('click', ()=>{
|
|
1508
1340
|
message = null;
|
|
1509
1341
|
bridge.undo();
|
|
1510
|
-
if (json) requestView();
|
|
1511
1342
|
});
|
|
1512
1343
|
panel.addEventListener('submit', (event)=>{
|
|
1513
1344
|
event.preventDefault();
|
|
@@ -1525,37 +1356,100 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1525
1356
|
setOpen(false);
|
|
1526
1357
|
}
|
|
1527
1358
|
});
|
|
1528
|
-
code.addEventListener('input', refresh);
|
|
1529
|
-
code.addEventListener('keydown', (event)=>{
|
|
1530
|
-
// Enter is a new line here; Ctrl or Cmd + Enter applies.
|
|
1531
|
-
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !event.isComposing) {
|
|
1532
|
-
event.preventDefault();
|
|
1533
|
-
void submit();
|
|
1534
|
-
} else if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1535
|
-
// A tab, not the next button (Shift + Tab still leaves). Through
|
|
1536
|
-
// execCommand where it works, so the browser's undo has it.
|
|
1537
|
-
event.preventDefault();
|
|
1538
|
-
let inserted = false;
|
|
1539
|
-
try {
|
|
1540
|
-
inserted = doc.execCommand('insertText', false, '\t');
|
|
1541
|
-
} catch {}
|
|
1542
|
-
if (!inserted) {
|
|
1543
|
-
code.setRangeText('\t', code.selectionStart, code.selectionEnd, 'end');
|
|
1544
|
-
}
|
|
1545
|
-
refresh();
|
|
1546
|
-
} else if (event.key === 'Escape') {
|
|
1547
|
-
setOpen(false);
|
|
1548
|
-
}
|
|
1549
|
-
});
|
|
1550
1359
|
const unsubscribe = bridge.on('change', render);
|
|
1551
1360
|
refresh();
|
|
1552
1361
|
doc.body.appendChild(host);
|
|
1553
1362
|
return ()=>{
|
|
1554
1363
|
unsubscribe();
|
|
1364
|
+
unmountForm();
|
|
1555
1365
|
host.remove();
|
|
1556
1366
|
};
|
|
1557
1367
|
};
|
|
1558
1368
|
|
|
1369
|
+
// The CMS's entry form in an iframe, for editing on the site (the prompt
|
|
1370
|
+
// box's Form mode). The CMS renders the form at a one-time URL (`/site/form#
|
|
1371
|
+
// code=…`, from the site's `/cms/api/form`); this keeps it in step with the
|
|
1372
|
+
// bridge's in-memory content.
|
|
1373
|
+
//
|
|
1374
|
+
// Messages are only exchanged with the URL's origin, and only with the
|
|
1375
|
+
// iframe's window:
|
|
1376
|
+
// - form → bridge: { source: 'obelism-content-form', type: 'ready' |
|
|
1377
|
+
// 'change' | 'error' | 'height', content?, message?, height? } —
|
|
1378
|
+
// `change` carries the whole entry content after an edit, `height` the
|
|
1379
|
+
// form's own height in px, which the iframe takes (up to 60vh)
|
|
1380
|
+
// - bridge → form: { source: 'obelism-content-bridge', type: 'content',
|
|
1381
|
+
// entryId, content, locale, selectedUid }
|
|
1382
|
+
const FORM_SOURCE = 'obelism-content-form';
|
|
1383
|
+
// How long the form may take to say it is ready.
|
|
1384
|
+
const FORM_READY_TIMEOUT_MS = 30_000;
|
|
1385
|
+
const isObject = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
|
|
1386
|
+
// Mounts the form at `url` in `container`. The URL is single use: mount a
|
|
1387
|
+
// fresh one every time.
|
|
1388
|
+
const mountFormFrame = (win, container, url, title, handlers)=>{
|
|
1389
|
+
let parsed = null;
|
|
1390
|
+
try {
|
|
1391
|
+
parsed = new URL(url);
|
|
1392
|
+
} catch {}
|
|
1393
|
+
if (!parsed || !/^https?:$/.test(parsed.protocol)) {
|
|
1394
|
+
throw new Error('Obelism Content sent no form to open');
|
|
1395
|
+
}
|
|
1396
|
+
const origin = parsed.origin;
|
|
1397
|
+
const iframe = container.ownerDocument.createElement('iframe');
|
|
1398
|
+
iframe.title = title;
|
|
1399
|
+
iframe.src = url;
|
|
1400
|
+
let ready = false;
|
|
1401
|
+
let latest = null;
|
|
1402
|
+
const post = ()=>{
|
|
1403
|
+
if (!ready || !latest) return;
|
|
1404
|
+
iframe.contentWindow?.postMessage({
|
|
1405
|
+
source: BRIDGE_SOURCE,
|
|
1406
|
+
type: 'content',
|
|
1407
|
+
...latest
|
|
1408
|
+
}, origin);
|
|
1409
|
+
};
|
|
1410
|
+
const timer = setTimeout(()=>{
|
|
1411
|
+
if (!ready) handlers.onError('The form did not open');
|
|
1412
|
+
}, FORM_READY_TIMEOUT_MS);
|
|
1413
|
+
const onMessage = (event)=>{
|
|
1414
|
+
if (event.origin !== origin || !iframe.contentWindow || event.source !== iframe.contentWindow) {
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
const data = event.data;
|
|
1418
|
+
if (!data || data.source !== FORM_SOURCE) return;
|
|
1419
|
+
if (data.type === 'ready') {
|
|
1420
|
+
// Again after the form reloads itself: it gets the content again.
|
|
1421
|
+
ready = true;
|
|
1422
|
+
clearTimeout(timer);
|
|
1423
|
+
handlers.onReady();
|
|
1424
|
+
post();
|
|
1425
|
+
} else if (data.type === 'change' && isObject(data.content)) {
|
|
1426
|
+
handlers.onChange(data.content, typeof data.entryId === 'string' ? data.entryId : undefined);
|
|
1427
|
+
} else if (data.type === 'height') {
|
|
1428
|
+
const height = data.height;
|
|
1429
|
+
if (typeof height === 'number' && Number.isFinite(height)) {
|
|
1430
|
+
// A short page's form is short; a long one scrolls inside.
|
|
1431
|
+
const px = Math.round(Math.min(Math.max(height, 80), 4000));
|
|
1432
|
+
iframe.style.height = `min(${px}px, 60vh)`;
|
|
1433
|
+
}
|
|
1434
|
+
} else if (data.type === 'error') {
|
|
1435
|
+
handlers.onError(typeof data.message === 'string' && data.message ? data.message : 'The form failed');
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
win.addEventListener('message', onMessage);
|
|
1439
|
+
container.appendChild(iframe);
|
|
1440
|
+
return {
|
|
1441
|
+
show (content) {
|
|
1442
|
+
latest = content;
|
|
1443
|
+
post();
|
|
1444
|
+
},
|
|
1445
|
+
destroy () {
|
|
1446
|
+
clearTimeout(timer);
|
|
1447
|
+
win.removeEventListener('message', onMessage);
|
|
1448
|
+
iframe.remove();
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
};
|
|
1452
|
+
|
|
1559
1453
|
// The bridge on a site signed in to edit (`/cms/login`, from
|
|
1560
1454
|
// @obelism/content/site), outside the visual editor. It plays the editor's
|
|
1561
1455
|
// part itself: it keeps the changed content in memory, asks the site's
|
|
@@ -1565,9 +1459,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1565
1459
|
//
|
|
1566
1460
|
// What it edits is the page's entry — the outermost element marked with
|
|
1567
1461
|
// `editable(entry)` — or, for a selected block, the entry around that block.
|
|
1568
|
-
// Besides prompts it
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1462
|
+
// Besides prompts it shows the CMS's own entry form (`openForm`, an iframe
|
|
1463
|
+
// kept in step with the content here, see formFrame.ts), edits JSON (`view`,
|
|
1464
|
+
// then `applyJson`: the raw alternative), and lists the space's pages to go
|
|
1465
|
+
// to one.
|
|
1571
1466
|
// The base path of the site's handler while signed in to edit, else null.
|
|
1572
1467
|
const readSiteEditing = (doc)=>{
|
|
1573
1468
|
for (const part of doc.cookie.split(';')){
|
|
@@ -1583,6 +1478,11 @@ const readSiteEditing = (doc)=>{
|
|
|
1583
1478
|
return null;
|
|
1584
1479
|
};
|
|
1585
1480
|
const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
|
|
1481
|
+
// Edits in the form further apart than this are separate undo steps.
|
|
1482
|
+
const FORM_BURST_MS = 1000;
|
|
1483
|
+
// How long the page waits for the form's edits to settle before it shows
|
|
1484
|
+
// them.
|
|
1485
|
+
const FORM_RENDER_MS = 300;
|
|
1586
1486
|
const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
|
|
1587
1487
|
const doc = win.document;
|
|
1588
1488
|
const emitter = createEmitter();
|
|
@@ -1593,6 +1493,11 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1593
1493
|
// Entries changed here since the last publish.
|
|
1594
1494
|
const touched = new Set();
|
|
1595
1495
|
let session = null;
|
|
1496
|
+
// The open form's, if any; and when the form last changed content, for
|
|
1497
|
+
// undo steps (-Infinity: the next edit starts a new step).
|
|
1498
|
+
let closeForm = null;
|
|
1499
|
+
let lastFormEdit = -Infinity;
|
|
1500
|
+
const renderTimers = new Map();
|
|
1596
1501
|
let state = INITIAL_STATE;
|
|
1597
1502
|
const setState = (next)=>{
|
|
1598
1503
|
state = {
|
|
@@ -1646,17 +1551,26 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1646
1551
|
const base = lang.split('-')[0] ?? '';
|
|
1647
1552
|
return codes.includes(base) ? base : session.space.defaultLocale;
|
|
1648
1553
|
};
|
|
1554
|
+
// One request per entry at a time: a second would overwrite edits made
|
|
1555
|
+
// while the first was out.
|
|
1556
|
+
const loading = new Map();
|
|
1649
1557
|
const load = async (entryId)=>{
|
|
1650
1558
|
const loaded = working.get(entryId);
|
|
1651
1559
|
if (loaded) return loaded;
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1560
|
+
let pending = loading.get(entryId);
|
|
1561
|
+
if (!pending) {
|
|
1562
|
+
pending = api(`entries/${encodeURIComponent(entryId)}`).then((entry)=>{
|
|
1563
|
+
const fresh = working.get(entryId) ?? {
|
|
1564
|
+
content: entry.content,
|
|
1565
|
+
saved: entry.content,
|
|
1566
|
+
revision: entry.draftRevision
|
|
1567
|
+
};
|
|
1568
|
+
working.set(entryId, fresh);
|
|
1569
|
+
return fresh;
|
|
1570
|
+
}).finally(()=>loading.delete(entryId));
|
|
1571
|
+
loading.set(entryId, pending);
|
|
1572
|
+
}
|
|
1573
|
+
return pending;
|
|
1660
1574
|
};
|
|
1661
1575
|
// The entry a prompt or JSON acts on, for the block `uid` (the selected
|
|
1662
1576
|
// one when left out).
|
|
@@ -1695,6 +1609,15 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1695
1609
|
});
|
|
1696
1610
|
win.requestAnimationFrame(()=>highlight(state.selectedUid));
|
|
1697
1611
|
};
|
|
1612
|
+
const logError = (error)=>console.error('[@obelism/content bridge]', error);
|
|
1613
|
+
// Renders once the form's edits settle.
|
|
1614
|
+
const renderSoon = (entryId)=>{
|
|
1615
|
+
clearTimeout(renderTimers.get(entryId));
|
|
1616
|
+
renderTimers.set(entryId, setTimeout(()=>{
|
|
1617
|
+
renderTimers.delete(entryId);
|
|
1618
|
+
void render(entryId).catch(logError);
|
|
1619
|
+
}, FORM_RENDER_MS));
|
|
1620
|
+
};
|
|
1698
1621
|
const afterStore = (event)=>{
|
|
1699
1622
|
emitter.emit(event, {});
|
|
1700
1623
|
if (emitter.listeners.update.size === 0) win.location.reload();
|
|
@@ -1711,6 +1634,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1711
1634
|
touched.add(entryId);
|
|
1712
1635
|
}
|
|
1713
1636
|
history.length = 0;
|
|
1637
|
+
lastFormEdit = -Infinity;
|
|
1714
1638
|
refreshUnsaved();
|
|
1715
1639
|
};
|
|
1716
1640
|
const has = (feature)=>state.features.includes(feature);
|
|
@@ -1743,6 +1667,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1743
1667
|
entryId,
|
|
1744
1668
|
content: entry.content
|
|
1745
1669
|
});
|
|
1670
|
+
lastFormEdit = -Infinity;
|
|
1746
1671
|
entry.content = result.content;
|
|
1747
1672
|
refreshUnsaved();
|
|
1748
1673
|
await render(entryId);
|
|
@@ -1821,6 +1746,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1821
1746
|
entryId,
|
|
1822
1747
|
content: entry.content
|
|
1823
1748
|
});
|
|
1749
|
+
lastFormEdit = -Infinity;
|
|
1824
1750
|
entry.content = result.content;
|
|
1825
1751
|
refreshUnsaved();
|
|
1826
1752
|
await render(entryId);
|
|
@@ -1837,6 +1763,91 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1837
1763
|
});
|
|
1838
1764
|
}
|
|
1839
1765
|
},
|
|
1766
|
+
openForm (container, options = {}) {
|
|
1767
|
+
const { title = 'Content form', onReady, onError } = options;
|
|
1768
|
+
closeForm?.();
|
|
1769
|
+
let closed = false;
|
|
1770
|
+
let frame = null;
|
|
1771
|
+
// What the form was last sent, or sent back itself.
|
|
1772
|
+
let shown = null;
|
|
1773
|
+
let unsubscribe = ()=>{};
|
|
1774
|
+
const fail = (error)=>{
|
|
1775
|
+
if (!closed) onError?.(error instanceof Error ? error.message : String(error));
|
|
1776
|
+
};
|
|
1777
|
+
const close = ()=>{
|
|
1778
|
+
if (closed) return;
|
|
1779
|
+
closed = true;
|
|
1780
|
+
unsubscribe();
|
|
1781
|
+
frame?.destroy();
|
|
1782
|
+
if (closeForm === close) closeForm = null;
|
|
1783
|
+
};
|
|
1784
|
+
closeForm = close;
|
|
1785
|
+
// Sends the form the entry for the selection, when anything of it
|
|
1786
|
+
// changed: prompts, undo, a reload after saving, the selection.
|
|
1787
|
+
const show = ()=>{
|
|
1788
|
+
if (closed || !frame) return;
|
|
1789
|
+
const entryId = entryFor(state.selectedUid);
|
|
1790
|
+
if (!entryId) return;
|
|
1791
|
+
const entry = working.get(entryId);
|
|
1792
|
+
if (!entry) {
|
|
1793
|
+
load(entryId).then(show, fail);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
const next = {
|
|
1797
|
+
entryId,
|
|
1798
|
+
content: entry.content,
|
|
1799
|
+
locale: locale(),
|
|
1800
|
+
selectedUid: state.selectedUid
|
|
1801
|
+
};
|
|
1802
|
+
if (shown && shown.entryId === next.entryId && shown.content === next.content && shown.locale === next.locale && shown.selectedUid === next.selectedUid) {
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
shown = next;
|
|
1806
|
+
frame.show(next);
|
|
1807
|
+
};
|
|
1808
|
+
const change = (content, from)=>{
|
|
1809
|
+
const entryId = from ?? shown?.entryId;
|
|
1810
|
+
const entry = entryId === undefined ? undefined : working.get(entryId);
|
|
1811
|
+
if (closed || entryId === undefined || !entry || same(content, entry.content)) {
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
const now = Date.now();
|
|
1815
|
+
if (now - lastFormEdit >= FORM_BURST_MS) {
|
|
1816
|
+
history.push({
|
|
1817
|
+
entryId,
|
|
1818
|
+
content: entry.content
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
lastFormEdit = now;
|
|
1822
|
+
entry.content = content;
|
|
1823
|
+
// Not sent back: the form has it.
|
|
1824
|
+
if (shown?.entryId === entryId) shown = {
|
|
1825
|
+
...shown,
|
|
1826
|
+
content
|
|
1827
|
+
};
|
|
1828
|
+
refreshUnsaved();
|
|
1829
|
+
renderSoon(entryId);
|
|
1830
|
+
};
|
|
1831
|
+
const start = async ()=>{
|
|
1832
|
+
if (!has('form')) throw new Error('You may not edit content here');
|
|
1833
|
+
await load(target(undefined).entryId);
|
|
1834
|
+
// A fresh one every time: the address works once, for a minute.
|
|
1835
|
+
const { url } = await api('form', {});
|
|
1836
|
+
if (closed) return;
|
|
1837
|
+
const address = typeof url === 'string' ? url : '';
|
|
1838
|
+
frame = mountFormFrame(win, container, address, title, {
|
|
1839
|
+
onReady: ()=>onReady?.(),
|
|
1840
|
+
onChange: change,
|
|
1841
|
+
onError: (message)=>onError?.(message)
|
|
1842
|
+
});
|
|
1843
|
+
unsubscribe = emitter.on('change', show);
|
|
1844
|
+
show();
|
|
1845
|
+
};
|
|
1846
|
+
start().catch(fail);
|
|
1847
|
+
return {
|
|
1848
|
+
close
|
|
1849
|
+
};
|
|
1850
|
+
},
|
|
1840
1851
|
async pages () {
|
|
1841
1852
|
const { pages } = await api('pages');
|
|
1842
1853
|
const preferred = [
|
|
@@ -1874,9 +1885,10 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1874
1885
|
const last = history.pop();
|
|
1875
1886
|
const entry = last && working.get(last.entryId);
|
|
1876
1887
|
if (!last || !entry) return;
|
|
1888
|
+
lastFormEdit = -Infinity;
|
|
1877
1889
|
entry.content = last.content;
|
|
1878
1890
|
refreshUnsaved();
|
|
1879
|
-
void render(last.entryId).catch(
|
|
1891
|
+
void render(last.entryId).catch(logError);
|
|
1880
1892
|
},
|
|
1881
1893
|
async save () {
|
|
1882
1894
|
if (!has('save')) throw new Error('You may not edit content here');
|
|
@@ -1939,6 +1951,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1939
1951
|
});
|
|
1940
1952
|
},
|
|
1941
1953
|
destroy () {
|
|
1954
|
+
closeForm?.();
|
|
1955
|
+
renderTimers.forEach((timer)=>clearTimeout(timer));
|
|
1956
|
+
renderTimers.clear();
|
|
1942
1957
|
cleanup.splice(0).forEach((undo)=>undo());
|
|
1943
1958
|
}
|
|
1944
1959
|
};
|