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