pict-section-prompteditor 1.0.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.
@@ -0,0 +1,1465 @@
1
+ 'use strict';
2
+
3
+ const libPictView = require('pict-view');
4
+ const libPictSectionContent = require('pict-section-content');
5
+ const libPictSectionModal = require('pict-section-modal');
6
+ const libPictSectionPicker = require('pict-section-picker');
7
+ const libMarkdownEditor = require('pict-section-markdowneditor');
8
+
9
+ const libProvider = require('../providers/PromptProvider-Base.js');
10
+ const libTypes = require('../types/PromptEditor-DefaultTypes.js');
11
+ const libCompiler = require('../compiler/PromptCompiler.js');
12
+ const libZip = require('../zip/PromptZip.js');
13
+ const libWordListTemplate = require('../templates/Pict-Template-WordListEntry.js');
14
+
15
+ /**
16
+ * pict-section-prompteditor -- craft, curate, generate.
17
+ *
18
+ * One section, three surfaces:
19
+ *
20
+ * Prompts a list of crafted prompts and an editor: title, prompt type,
21
+ * one markdown segment per type segment (fixed preambles render
22
+ * locked), template expression insertion, per-segment preview,
23
+ * a full pict-section-markdowneditor in a modal for rich edits,
24
+ * and a generate bar.
25
+ *
26
+ * Word lists the matrices behind {~WordListEntry:Name~}: words with
27
+ * weights (default 1), live percentage per entry.
28
+ *
29
+ * Generated every generation, rendered and browsable, downloadable as a
30
+ * zip of markdown files.
31
+ *
32
+ * State lives behind a PromptDataProvider; the default is in-memory in
33
+ * AppData, keyed per instance, so the section works with no server and
34
+ * multiple sections coexist on one page.
35
+ */
36
+
37
+ const _DefaultConfiguration =
38
+ {
39
+ ViewIdentifier: 'PromptEditor',
40
+
41
+ DefaultRenderable: 'PromptEditor-Section',
42
+ DefaultDestinationAddress: '#PromptEditor-Container',
43
+
44
+ AutoRender: false,
45
+
46
+ // ---- options a host overrides -------------------------------------------
47
+ // The data seam; null means the in-memory default (AppData-backed).
48
+ DataProvider: null,
49
+ // The prompt type set; null means DefaultPromptTypes. Pass your own array
50
+ // to replace the built-ins outright.
51
+ PromptTypes: null,
52
+ // Stamped onto prompts and generated output the user creates.
53
+ CurrentUser: { Key: '', Name: 'Anonymous' },
54
+ // Render-only: no creation, editing, generation, or deletion.
55
+ ReadOnly: false,
56
+ // The random source for weighted draws; null means Math.random.
57
+ RandomFunction: null,
58
+ // CodeMirror 6 modules for the rich editor modal; null falls back to
59
+ // window.CodeMirrorModules; absent entirely hides the rich edit button.
60
+ CodeMirrorModules: null,
61
+
62
+ // Compile shape.
63
+ IncludeTitleHeading: true,
64
+ SegmentHeadingLevel: 2,
65
+
66
+ // Generation + download.
67
+ GenerateDefaultCount: 5,
68
+ GenerateMaxCount: 100,
69
+ ZipFileName: 'prompts.zip',
70
+
71
+ Title: 'Prompt Editor',
72
+
73
+ // Event hooks (all optional): a host wires these to autosave, audit,
74
+ // ratings, collaboration -- whatever sits on top.
75
+ onPromptSaved: null,
76
+ onPromptDeleted: null,
77
+ onWordListSaved: null,
78
+ onWordListDeleted: null,
79
+ onGenerated: null,
80
+ onChange: null,
81
+
82
+ CSSPriority: 500,
83
+ CSS: /*css*/`
84
+ .pspe { font-size: 14px; color: var(--theme-color-text-primary, #1f2430); }
85
+ .pspe-header { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
86
+ .pspe-title { font-size: 16px; font-weight: 650; margin-right: auto; }
87
+ .pspe-tabs { display: flex; gap: 4px; }
88
+ .pspe-tab {
89
+ font: inherit; font-size: 13px; padding: 6px 12px; cursor: pointer;
90
+ border: 1px solid var(--theme-color-border-default, #d6dde3);
91
+ background: var(--theme-color-background-panel, #fff);
92
+ color: var(--theme-color-text-secondary, #5b6470); border-radius: 8px;
93
+ }
94
+ .pspe-tab-on { background: var(--theme-color-brand-primary, #2880a6); border-color: var(--theme-color-brand-primary, #2880a6); color: var(--theme-color-text-on-brand, #fff); }
95
+ .pspe-tab-count { opacity: 0.75; font-size: 11px; margin-left: 4px; }
96
+
97
+ .pspe-btn {
98
+ font: inherit; font-size: 13px; padding: 6px 12px; cursor: pointer; border-radius: 8px;
99
+ border: 1px solid var(--theme-color-border-default, #d6dde3);
100
+ background: var(--theme-color-background-panel, #fff);
101
+ color: var(--theme-color-text-primary, #1f2430);
102
+ }
103
+ .pspe-btn:hover { background: var(--theme-color-background-hover, #eef2f6); }
104
+ .pspe-btn-primary { background: var(--theme-color-brand-primary, #2880a6); border-color: var(--theme-color-brand-primary, #2880a6); color: var(--theme-color-text-on-brand, #fff); }
105
+ .pspe-btn-primary:hover { filter: brightness(0.95); background: var(--theme-color-brand-primary, #2880a6); }
106
+ .pspe-btn-danger { color: var(--theme-color-status-error, #c0392b); }
107
+ .pspe-btn-sm { font-size: 12px; padding: 3px 9px; }
108
+
109
+ .pspe-split { display: flex; gap: 10px; align-items: flex-start; }
110
+ /* Width is owned by the pict-section-modal panel handle (resizable,
111
+ collapsible, persisted); flex-basis auto lets the inline width rule. */
112
+ .pspe-rail { flex: 0 0 auto; min-width: 0; display: flex; flex-direction: column; gap: 8px; box-sizing: border-box; }
113
+ /* The panel's edge (resize strip + collapse tab) is a zero-width flex
114
+ sibling whose children span its height; align-items: flex-start above
115
+ would collapse it to zero height and leave nothing to grab. */
116
+ .pspe-split > .pict-panel-edge { align-self: stretch; }
117
+ .pspe-main { flex: 1; min-width: 0; }
118
+ .pspe-rail-list { display: flex; flex-direction: column; gap: 4px; }
119
+ .pspe-rail-item {
120
+ text-align: left; font: inherit; font-size: 13px; cursor: pointer; padding: 8px 10px;
121
+ border: 1px solid var(--theme-color-border-light, #e7ecf0); border-radius: 8px;
122
+ background: var(--theme-color-background-panel, #fff); color: inherit;
123
+ }
124
+ .pspe-rail-item:hover { border-color: var(--theme-color-border-default, #d6dde3); }
125
+ .pspe-rail-item-on { border-color: var(--theme-color-brand-primary, #2880a6); box-shadow: 0 0 0 1px var(--theme-color-brand-primary, #2880a6) inset; }
126
+ .pspe-rail-item-name { font-weight: 600; display: block; }
127
+ .pspe-rail-item-sub { font-size: 11.5px; color: var(--theme-color-text-muted, #97a1ab); display: block; margin-top: 1px; }
128
+ .pspe-empty { color: var(--theme-color-text-muted, #97a1ab); font-size: 13px; padding: 14px 4px; }
129
+
130
+ .pspe-input, .pspe-select {
131
+ font: inherit; font-size: 13px; padding: 7px 9px; box-sizing: border-box;
132
+ border: 1px solid var(--theme-color-border-default, #d6dde3); border-radius: 7px;
133
+ background: var(--theme-color-background-panel, #fff); color: inherit;
134
+ }
135
+ .pspe-input:focus, .pspe-select:focus, .pspe-textarea:focus { outline: none; border-color: var(--theme-color-brand-primary, #2880a6); }
136
+ .pspe-editor-head { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
137
+ .pspe-editor-title { flex: 1; min-width: 200px; font-weight: 650; font-size: 15px; }
138
+ .pspe-type-desc { font-size: 12px; color: var(--theme-color-text-muted, #97a1ab); margin: -6px 0 12px; }
139
+
140
+ .pspe-segment {
141
+ border: 1px solid var(--theme-color-border-light, #e7ecf0); border-radius: 10px;
142
+ background: var(--theme-color-background-panel, #fff); padding: 12px 14px; margin-bottom: 10px;
143
+ }
144
+ .pspe-segment-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
145
+ .pspe-segment-name { font-weight: 650; font-size: 13.5px; }
146
+ .pspe-segment-guidance { font-size: 12px; color: var(--theme-color-text-muted, #97a1ab); margin-right: auto; }
147
+ .pspe-pill {
148
+ font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 8px; border-radius: 999px;
149
+ background: var(--theme-color-background-tertiary, #eef2f6); color: var(--theme-color-text-secondary, #5b6470);
150
+ }
151
+ .pspe-textarea {
152
+ width: 100%; box-sizing: border-box; min-height: 84px; resize: vertical; font-size: 13px;
153
+ font-family: var(--theme-typography-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
154
+ padding: 8px 10px; border: 1px solid var(--theme-color-border-default, #d6dde3); border-radius: 7px;
155
+ background: var(--theme-color-background-panel, #fff); color: inherit; line-height: 1.5;
156
+ }
157
+ .pspe-segment-tools { display: flex; gap: 8px; align-items: center; margin-top: 6px; flex-wrap: wrap; }
158
+ .pspe-insert-picker { min-width: 190px; font-size: 12px; }
159
+ .pspe-segment-preview { border: 1px dashed var(--theme-color-border-light, #e7ecf0); border-radius: 7px; padding: 4px 12px; margin-top: 8px; }
160
+ .pspe-fixed-body { border-left: 3px solid var(--theme-color-border-default, #d6dde3); padding: 2px 12px; color: var(--theme-color-text-secondary, #5b6470); }
161
+ .pspe-md p:first-child { margin-top: 6px; } .pspe-md p:last-child { margin-bottom: 6px; }
162
+ .pspe-md pre { background: var(--theme-color-background-secondary, #f7f9fb); padding: 8px 10px; border-radius: 6px; overflow-x: auto; }
163
+
164
+ .pspe-generate {
165
+ display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding: 11px 14px;
166
+ border: 1px solid var(--theme-color-border-light, #e7ecf0); border-radius: 10px;
167
+ background: var(--theme-color-background-secondary, #f7f9fb);
168
+ }
169
+ .pspe-generate-count { width: 70px; }
170
+ .pspe-generate-note { font-size: 12px; color: var(--theme-color-text-muted, #97a1ab); margin-left: auto; }
171
+ .pspe-preview-panel { margin-top: 12px; }
172
+ .pspe-preview-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
173
+ .pspe-preview-title { font-weight: 650; font-size: 13.5px; }
174
+ .pspe-preview-note { font-size: 12px; color: var(--theme-color-text-muted, #97a1ab); margin-right: auto; }
175
+ .pspe-btn-on, .pspe-btn-on:hover { background: var(--theme-color-brand-primary, #2880a6); border-color: var(--theme-color-brand-primary, #2880a6); color: var(--theme-color-text-on-brand, #fff); }
176
+ .pspe-preview-raw {
177
+ margin: 0; padding: 12px 16px; white-space: pre-wrap; word-break: break-word; font-size: 12.5px; line-height: 1.55;
178
+ font-family: var(--theme-typography-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
179
+ }
180
+
181
+ .pspe-entry-table { width: 100%; border-collapse: collapse; }
182
+ .pspe-entry-table th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--theme-color-text-muted, #97a1ab); padding: 4px 6px; }
183
+ .pspe-entry-table td { padding: 3px 6px; }
184
+ .pspe-entry-word { width: 100%; }
185
+ .pspe-entry-weight { width: 70px; }
186
+ .pspe-entry-pct { font-size: 12px; color: var(--theme-color-text-secondary, #5b6470); white-space: nowrap; font-variant-numeric: tabular-nums; }
187
+
188
+ .pspe-gen-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
189
+ .pspe-gen-note { font-size: 12.5px; color: var(--theme-color-text-secondary, #5b6470); margin-right: auto; }
190
+ .pspe-viewer {
191
+ border: 1px solid var(--theme-color-border-light, #e7ecf0); border-radius: 10px;
192
+ background: var(--theme-color-background-panel, #fff); padding: 6px 16px;
193
+ }
194
+ .pspe-viewer-tools { display: flex; gap: 8px; justify-content: flex-end; padding: 8px 0 2px; }
195
+
196
+ .pspe-rich-host { min-height: 320px; }
197
+ `,
198
+
199
+ Templates:
200
+ [
201
+ {
202
+ Hash: 'PromptEditor-Section',
203
+ Template: /*html*/`
204
+ <div class="pspe">
205
+ <div class="pspe-header">
206
+ <div class="pspe-title">{~D:AppData.PromptEditorActive.Title~}</div>
207
+ {~TS:PromptEditor-Tab:AppData.PromptEditorActive.Tabs~}
208
+ </div>
209
+ {~TS:PromptEditor-PromptsPane:AppData.PromptEditorActive.PromptsPaneSlot~}
210
+ {~TS:PromptEditor-WordListsPane:AppData.PromptEditorActive.WordListsPaneSlot~}
211
+ {~TS:PromptEditor-GeneratedPane:AppData.PromptEditorActive.GeneratedPaneSlot~}
212
+ </div>`
213
+ },
214
+ { Hash: 'PromptEditor-Tab', Template: /*html*/`<button class="pspe-tab {~D:Record.OnClass~}" onclick="_Pict.views['{~D:Record.ViewHash~}'].selectTab('{~D:Record.Key~}')">{~D:Record.Label~}<span class="pspe-tab-count">{~D:Record.Count~}</span></button>` },
215
+
216
+ // ---- prompts pane ----------------------------------------------------
217
+ {
218
+ Hash: 'PromptEditor-PromptsPane',
219
+ Template: /*html*/`
220
+ <div class="pspe-split">
221
+ <div class="pspe-rail" id="pspe-rail-{~D:Record.ViewHash~}-prompts">
222
+ {~TS:PromptEditor-NewPromptButton:Record.NewSlot~}
223
+ <div class="pspe-rail-list">
224
+ {~TS:PromptEditor-PromptListItem:Record.Prompts~}
225
+ </div>
226
+ {~TS:PromptEditor-EmptyNote:Record.EmptySlot~}
227
+ </div>
228
+ <div class="pspe-main">
229
+ {~TS:PromptEditor-PromptEditorPanel:Record.EditorSlot~}
230
+ {~TS:PromptEditor-EmptyNote:Record.NoSelectionSlot~}
231
+ </div>
232
+ </div>`
233
+ },
234
+ { Hash: 'PromptEditor-NewPromptButton', Template: /*html*/`<button class="pspe-btn pspe-btn-primary" onclick="_Pict.views['{~D:Record.ViewHash~}'].newPrompt()">+ New prompt</button>` },
235
+ { Hash: 'PromptEditor-EmptyNote', Template: /*html*/`<div class="pspe-empty">{~D:Record.Text~}</div>` },
236
+ {
237
+ Hash: 'PromptEditor-PromptListItem',
238
+ Template: /*html*/`
239
+ <button class="pspe-rail-item {~D:Record.OnClass~}" onclick="_Pict.views['{~D:Record.ViewHash~}'].selectPrompt('{~D:Record.Key~}')">
240
+ <span class="pspe-rail-item-name" id="pspe-prname-{~D:Record.ViewHash~}-{~D:Record.Key~}">{~D:Record.Title~}</span>
241
+ <span class="pspe-rail-item-sub">{~D:Record.TypeName~}</span>
242
+ </button>`
243
+ },
244
+ {
245
+ Hash: 'PromptEditor-PromptEditorPanel',
246
+ Template: /*html*/`
247
+ <div class="pspe-editor-head">
248
+ <input class="pspe-input pspe-editor-title" value="{~D:Record.Title~}" placeholder="Prompt title"
249
+ oninput="_Pict.views['{~D:Record.ViewHash~}'].cachePromptTitle(this.value)"
250
+ onchange="_Pict.views['{~D:Record.ViewHash~}'].savePromptTitle()">
251
+ <select class="pspe-select" onchange="_Pict.views['{~D:Record.ViewHash~}'].setPromptType(this.value)">
252
+ {~TS:PromptEditor-TypeOption:Record.TypeOptions~}
253
+ </select>
254
+ {~TS:PromptEditor-PromptActions:Record.ActionsSlot~}
255
+ </div>
256
+ <div class="pspe-type-desc">{~D:Record.TypeDescription~}</div>
257
+ {~TS:PromptEditor-Segment:Record.Segments~}
258
+ {~TS:PromptEditor-GenerateBar:Record.GenerateSlot~}
259
+ {~TS:PromptEditor-PreviewPanel:Record.PreviewPanelSlot~}`
260
+ },
261
+ {
262
+ Hash: 'PromptEditor-PreviewPanel',
263
+ Template: /*html*/`
264
+ <div class="pspe-preview-panel">
265
+ <div class="pspe-preview-head">
266
+ <span class="pspe-preview-title">Preview roll</span>
267
+ <span class="pspe-preview-note">one unsaved generation</span>
268
+ <button class="pspe-btn pspe-btn-sm{~D:Record.FormattedOnClass~}" onclick="_Pict.views['{~D:Record.ViewHash~}'].setPreviewMode('rendered')">Formatted</button>
269
+ <button class="pspe-btn pspe-btn-sm{~D:Record.RawOnClass~}" onclick="_Pict.views['{~D:Record.ViewHash~}'].setPreviewMode('raw')">Markdown</button>
270
+ <button class="pspe-btn pspe-btn-sm" onclick="_Pict.views['{~D:Record.ViewHash~}'].copyPreview()">Copy</button>
271
+ <button class="pspe-btn pspe-btn-sm" onclick="_Pict.views['{~D:Record.ViewHash~}'].previewOnce()">Reroll</button>
272
+ <button class="pspe-btn pspe-btn-sm" onclick="_Pict.views['{~D:Record.ViewHash~}'].closePreview()">Close</button>
273
+ </div>
274
+ {~TS:PromptEditor-PreviewRendered:Record.RenderedSlot~}
275
+ {~TS:PromptEditor-PreviewRaw:Record.RawSlot~}
276
+ </div>`
277
+ },
278
+ { Hash: 'PromptEditor-PreviewRendered', Template: /*html*/`<div class="pspe-viewer pspe-md">{~D:Record.MarkdownHtml~}</div>` },
279
+ { Hash: 'PromptEditor-PreviewRaw', Template: /*html*/`<pre class="pspe-viewer pspe-preview-raw">{~D:Record.MarkdownEscaped~}</pre>` },
280
+ { Hash: 'PromptEditor-TypeOption', Template: /*html*/`<option value="{~D:Record.Value~}" {~D:Record.SelectedAttr~}>{~D:Record.Label~}</option>` },
281
+ {
282
+ Hash: 'PromptEditor-PromptActions',
283
+ Template: /*html*/`<button class="pspe-btn pspe-btn-sm" title="Duplicate this prompt" onclick="_Pict.views['{~D:Record.ViewHash~}'].duplicatePrompt()">Duplicate</button><button class="pspe-btn pspe-btn-sm pspe-btn-danger" onclick="_Pict.views['{~D:Record.ViewHash~}'].deletePrompt()">Delete</button>`
284
+ },
285
+ {
286
+ Hash: 'PromptEditor-Segment',
287
+ Template: /*html*/`
288
+ <div class="pspe-segment">
289
+ <div class="pspe-segment-head">
290
+ <span class="pspe-segment-name">{~D:Record.Name~}</span>
291
+ <span class="pspe-segment-guidance">{~D:Record.Guidance~}</span>
292
+ {~TS:PromptEditor-SegmentPill:Record.PillSlot~}
293
+ </div>
294
+ {~TS:PromptEditor-SegmentFixed:Record.FixedSlot~}
295
+ {~TS:PromptEditor-SegmentEdit:Record.EditSlot~}
296
+ {~TS:PromptEditor-SegmentPreview:Record.PreviewSlot~}
297
+ </div>`
298
+ },
299
+ { Hash: 'PromptEditor-SegmentPill', Template: /*html*/`<span class="pspe-pill">{~D:Record.Label~}</span>` },
300
+ { Hash: 'PromptEditor-SegmentFixed', Template: /*html*/`<div class="pspe-fixed-body pspe-md">{~D:Record.BodyHtml~}</div>` },
301
+ {
302
+ Hash: 'PromptEditor-SegmentEdit',
303
+ Template: /*html*/`
304
+ <textarea class="pspe-textarea" id="{~D:Record.TextareaId~}" placeholder="{~D:Record.Placeholder~}"
305
+ oninput="_Pict.views['{~D:Record.ViewHash~}'].cacheSegment('{~D:Record.SegmentKey~}', this.value)"
306
+ onchange="_Pict.views['{~D:Record.ViewHash~}'].saveSegment('{~D:Record.SegmentKey~}')">{~D:Record.Body~}</textarea>
307
+ <div class="pspe-segment-tools">
308
+ <div class="pspe-insert-picker" id="pspe-ins-{~D:Record.ViewHash~}-{~D:Record.SegmentKey~}"></div>
309
+ <button class="pspe-btn pspe-btn-sm" onclick="_Pict.views['{~D:Record.ViewHash~}'].toggleSegmentPreview('{~D:Record.SegmentKey~}')">{~D:Record.PreviewLabel~}</button>
310
+ {~TS:PromptEditor-SegmentRichButton:Record.RichSlot~}
311
+ </div>`
312
+ },
313
+ { Hash: 'PromptEditor-SegmentRichButton', Template: /*html*/`<button class="pspe-btn pspe-btn-sm" onclick="_Pict.views['{~D:Record.ViewHash~}'].openRichEditor('{~D:Record.SegmentKey~}')">Open in editor</button>` },
314
+ { Hash: 'PromptEditor-SegmentPreview', Template: /*html*/`<div class="pspe-segment-preview pspe-md">{~D:Record.BodyHtml~}</div>` },
315
+ {
316
+ Hash: 'PromptEditor-GenerateBar',
317
+ Template: /*html*/`
318
+ <div class="pspe-generate">
319
+ <input type="number" min="1" max="{~D:Record.MaxCount~}" class="pspe-input pspe-generate-count" value="{~D:Record.Count~}"
320
+ onchange="_Pict.views['{~D:Record.ViewHash~}'].setGenerateCount(this.value)">
321
+ <button class="pspe-btn pspe-btn-primary" onclick="_Pict.views['{~D:Record.ViewHash~}'].generatePrompts()">Generate</button>
322
+ <button class="pspe-btn" onclick="_Pict.views['{~D:Record.ViewHash~}'].previewOnce()">Preview one</button>
323
+ <span class="pspe-generate-note">{~D:Record.Note~}</span>
324
+ </div>`
325
+ },
326
+
327
+ // ---- word lists pane -------------------------------------------------
328
+ {
329
+ Hash: 'PromptEditor-WordListsPane',
330
+ Template: /*html*/`
331
+ <div class="pspe-split">
332
+ <div class="pspe-rail" id="pspe-rail-{~D:Record.ViewHash~}-wordlists">
333
+ {~TS:PromptEditor-NewWordListButton:Record.NewSlot~}
334
+ <div class="pspe-rail-list">
335
+ {~TS:PromptEditor-WordListItem:Record.WordLists~}
336
+ </div>
337
+ {~TS:PromptEditor-EmptyNote:Record.EmptySlot~}
338
+ </div>
339
+ <div class="pspe-main">
340
+ {~TS:PromptEditor-WordListDetail:Record.DetailSlot~}
341
+ {~TS:PromptEditor-EmptyNote:Record.NoSelectionSlot~}
342
+ </div>
343
+ </div>`
344
+ },
345
+ { Hash: 'PromptEditor-NewWordListButton', Template: /*html*/`<button class="pspe-btn pspe-btn-primary" onclick="_Pict.views['{~D:Record.ViewHash~}'].newWordList()">+ New word list</button>` },
346
+ {
347
+ Hash: 'PromptEditor-WordListItem',
348
+ Template: /*html*/`
349
+ <button class="pspe-rail-item {~D:Record.OnClass~}" onclick="_Pict.views['{~D:Record.ViewHash~}'].selectWordList('{~D:Record.Key~}')">
350
+ <span class="pspe-rail-item-name" id="pspe-wlname-{~D:Record.ViewHash~}-{~D:Record.Key~}">{~D:Record.Name~}</span>
351
+ <span class="pspe-rail-item-sub" id="pspe-wlsub-{~D:Record.ViewHash~}-{~D:Record.Key~}">{~D:Record.Summary~}</span>
352
+ </button>`
353
+ },
354
+ {
355
+ Hash: 'PromptEditor-WordListDetail',
356
+ Template: /*html*/`
357
+ <div class="pspe-editor-head">
358
+ <input class="pspe-input pspe-editor-title" value="{~D:Record.Name~}" placeholder="Word list name"
359
+ oninput="_Pict.views['{~D:Record.ViewHash~}'].cacheWordListName(this.value)"
360
+ onchange="_Pict.views['{~D:Record.ViewHash~}'].saveWordListName()">
361
+ <button class="pspe-btn pspe-btn-sm pspe-btn-danger" onclick="_Pict.views['{~D:Record.ViewHash~}'].deleteWordList()">Delete</button>
362
+ </div>
363
+ <div class="pspe-type-desc">Reference it in any segment as <code>{~D:Record.ExpressionExample~}</code>. Weights set each word's share of the draws.</div>
364
+ <table class="pspe-entry-table">
365
+ <thead><tr><th>Word</th><th>Weight</th><th>Share</th><th></th></tr></thead>
366
+ <tbody>{~TS:PromptEditor-WordListEntryRow:Record.Entries~}</tbody>
367
+ </table>
368
+ <div class="pspe-segment-tools">
369
+ <button class="pspe-btn pspe-btn-sm" onclick="_Pict.views['{~D:Record.ViewHash~}'].addWordListEntry()">+ Add word</button>
370
+ </div>`
371
+ },
372
+ {
373
+ Hash: 'PromptEditor-WordListEntryRow',
374
+ Template: /*html*/`
375
+ <tr>
376
+ <td><input class="pspe-input pspe-entry-word" value="{~D:Record.Word~}" placeholder="word or phrase"
377
+ oninput="_Pict.views['{~D:Record.ViewHash~}'].cacheEntryWord({~D:Record.Index~}, this.value)"
378
+ onchange="_Pict.views['{~D:Record.ViewHash~}'].saveEntries()"></td>
379
+ <td><input type="number" min="0" step="1" class="pspe-input pspe-entry-weight" value="{~D:Record.Weight~}"
380
+ oninput="_Pict.views['{~D:Record.ViewHash~}'].cacheEntryWeight({~D:Record.Index~}, this.value)"
381
+ onchange="_Pict.views['{~D:Record.ViewHash~}'].saveEntries()"></td>
382
+ <td class="pspe-entry-pct" id="pspe-pct-{~D:Record.ViewHash~}-{~D:Record.Index~}">{~D:Record.Percent~}</td>
383
+ <td><button class="pspe-btn pspe-btn-sm pspe-btn-danger" title="Remove" onclick="_Pict.views['{~D:Record.ViewHash~}'].removeWordListEntry({~D:Record.Index~})">×</button></td>
384
+ </tr>`
385
+ },
386
+
387
+ // ---- generated pane ----------------------------------------------------
388
+ {
389
+ Hash: 'PromptEditor-GeneratedPane',
390
+ Template: /*html*/`
391
+ <div class="pspe-gen-toolbar">
392
+ <span class="pspe-gen-note">{~D:Record.Note~}</span>
393
+ {~TS:PromptEditor-GeneratedTools:Record.ToolsSlot~}
394
+ </div>
395
+ <div class="pspe-split">
396
+ <div class="pspe-rail" id="pspe-rail-{~D:Record.ViewHash~}-generated">
397
+ <div class="pspe-rail-list">
398
+ {~TS:PromptEditor-GeneratedRow:Record.Generated~}
399
+ </div>
400
+ {~TS:PromptEditor-EmptyNote:Record.EmptySlot~}
401
+ </div>
402
+ <div class="pspe-main">
403
+ {~TS:PromptEditor-GeneratedViewer:Record.ViewerSlot~}
404
+ {~TS:PromptEditor-EmptyNote:Record.NoSelectionSlot~}
405
+ </div>
406
+ </div>`
407
+ },
408
+ {
409
+ Hash: 'PromptEditor-GeneratedTools',
410
+ Template: /*html*/`<button class="pspe-btn pspe-btn-primary" onclick="_Pict.views['{~D:Record.ViewHash~}'].downloadZip()">Download zip</button><button class="pspe-btn pspe-btn-danger" onclick="_Pict.views['{~D:Record.ViewHash~}'].clearGenerated()">Clear all</button>`
411
+ },
412
+ {
413
+ Hash: 'PromptEditor-GeneratedRow',
414
+ Template: /*html*/`
415
+ <button class="pspe-rail-item {~D:Record.OnClass~}" onclick="_Pict.views['{~D:Record.ViewHash~}'].selectGenerated('{~D:Record.Key~}')">
416
+ <span class="pspe-rail-item-name">{~D:Record.FileName~}</span>
417
+ <span class="pspe-rail-item-sub">{~D:Record.Sub~}</span>
418
+ </button>`
419
+ },
420
+ {
421
+ Hash: 'PromptEditor-GeneratedViewer',
422
+ Template: /*html*/`
423
+ <div class="pspe-viewer-tools">
424
+ <button class="pspe-btn pspe-btn-sm" onclick="_Pict.views['{~D:Record.ViewHash~}'].copyGenerated()">Copy markdown</button>
425
+ <button class="pspe-btn pspe-btn-sm pspe-btn-danger" onclick="_Pict.views['{~D:Record.ViewHash~}'].deleteGenerated()">Delete</button>
426
+ </div>
427
+ <div class="pspe-viewer pspe-md">{~D:Record.MarkdownHtml~}</div>`
428
+ }
429
+ ],
430
+
431
+ Renderables:
432
+ [
433
+ {
434
+ RenderableHash: 'PromptEditor-Section',
435
+ TemplateHash: 'PromptEditor-Section',
436
+ ContentDestinationAddress: '#PromptEditor-Container',
437
+ RenderMethod: 'replace'
438
+ }
439
+ ]
440
+ };
441
+
442
+ class PictViewPromptEditor extends libPictView
443
+ {
444
+ constructor(pFable, pOptions, pServiceHash)
445
+ {
446
+ let tmpOptions = Object.assign({}, JSON.parse(JSON.stringify(_DefaultConfiguration)), pOptions);
447
+ // Function options do not survive the JSON clone of defaults; restore.
448
+ ['DataProvider', 'PromptTypes', 'RandomFunction', 'CodeMirrorModules',
449
+ 'onPromptSaved', 'onPromptDeleted', 'onWordListSaved', 'onWordListDeleted', 'onGenerated', 'onChange']
450
+ .forEach((pKey) => { if (pOptions && typeof pOptions[pKey] !== 'undefined') { tmpOptions[pKey] = pOptions[pKey]; } });
451
+ super(pFable, tmpOptions, pServiceHash);
452
+
453
+ this._provider = null;
454
+ this._types = libTypes.resolvePromptTypes(this.options.PromptTypes);
455
+ this._loaded = { WordLists: [], Prompts: [], Generated: [] };
456
+ this._ui =
457
+ {
458
+ Tab: 'prompts',
459
+ ActivePromptKey: null,
460
+ ActiveWordListKey: null,
461
+ ActiveGeneratedKey: null,
462
+ GenerateCount: this.options.GenerateDefaultCount,
463
+ PreviewSegments: {},
464
+ Preview: null,
465
+ PreviewMode: 'rendered'
466
+ };
467
+ this._state = {};
468
+ this._richEditorViewHash = null;
469
+ this._entriesSaveTimer = null;
470
+ this._railPanelHandle = null;
471
+ }
472
+
473
+ // ---- lifecycle -----------------------------------------------------------
474
+ onBeforeInitialize()
475
+ {
476
+ this._initProvider();
477
+ this._ensureSupportViews();
478
+ this._registerTemplateExpression();
479
+ return super.onBeforeInitialize();
480
+ }
481
+
482
+ onAfterInitializeAsync(fCallback)
483
+ {
484
+ this.load().then(() => fCallback()).catch(() => { this._shape(); this.render(); fCallback(); });
485
+ }
486
+
487
+ onBeforeRender(pRenderable)
488
+ {
489
+ this.pict.AppData.PromptEditorActive = this._state;
490
+ return super.onBeforeRender(pRenderable);
491
+ }
492
+
493
+ onAfterRender(pRenderable, pAddress, pRecord, pContent)
494
+ {
495
+ this._mountInsertPickers();
496
+ this._mountRailPanel();
497
+ if (this.pict.CSSMap) { this.pict.CSSMap.injectCSS(); }
498
+ return super.onAfterRender(pRenderable, pAddress, pRecord, pContent);
499
+ }
500
+
501
+ // The list rail is a pict-section-modal panel: draggable resize handle,
502
+ // collapse tab, width and collapsed state persisted per section. One
503
+ // persist key covers all three tabs so the rail feels like one control.
504
+ // Re-attached after every render (the render replaces the rail's DOM).
505
+ _mountRailPanel()
506
+ {
507
+ let tmpModal = this._modal();
508
+ if (!tmpModal || typeof tmpModal.panel !== 'function' || typeof document === 'undefined') { return; }
509
+ let tmpRailId = 'pspe-rail-' + this.Hash + '-' + this._ui.Tab;
510
+ if (!document.getElementById(tmpRailId)) { return; }
511
+ if (this._railPanelHandle)
512
+ {
513
+ try { this._railPanelHandle.destroy(); }
514
+ catch (pError) { /* the prior rail's DOM is already gone */ }
515
+ this._railPanelHandle = null;
516
+ }
517
+ this._railPanelHandle = tmpModal.panel('#' + tmpRailId,
518
+ {
519
+ position: 'left',
520
+ width: 320,
521
+ minWidth: 200,
522
+ maxWidth: 560,
523
+ collapsible: true,
524
+ persist: true,
525
+ persistKey: 'pspe-rail-' + this.Hash
526
+ });
527
+ }
528
+
529
+ // One searchable picker (pict-section-picker) per editable segment, rendered
530
+ // into the host div the segment template provides. createPicker is
531
+ // re-entrant -- it merges config into an existing view -- so every section
532
+ // render refreshes the option list and the OnChange closure, then repaints.
533
+ _mountInsertPickers()
534
+ {
535
+ let tmpPickerProvider = this.pict.providers['Pict-Section-Picker'];
536
+ if (!tmpPickerProvider || typeof document === 'undefined') { return; }
537
+ if (this._ui.Tab !== 'prompts' || this.options.ReadOnly) { return; }
538
+ let tmpPrompt = this._activePrompt();
539
+ if (!tmpPrompt) { return; }
540
+ if (!this.pict.AppData.PromptEditorInsertPick) { this.pict.AppData.PromptEditorInsertPick = {}; }
541
+ let tmpType = libTypes.getPromptType(this._types, tmpPrompt.TypeKey);
542
+ let tmpOptions = this._loaded.WordLists.map((pList) => ({ Value: pList.Name, Text: pList.Name }));
543
+ let tmpSelf = this;
544
+ (tmpType.Segments || []).forEach((pSegment) =>
545
+ {
546
+ if (pSegment.Fixed) { return; }
547
+ let tmpHostId = 'pspe-ins-' + this.Hash + '-' + pSegment.Key;
548
+ if (!document.getElementById(tmpHostId)) { return; }
549
+ let tmpPickerHash = 'PromptEditor-Ins-' + this.Hash + '-' + pSegment.Key;
550
+ let tmpPickerView = tmpPickerProvider.createPicker(tmpPickerHash,
551
+ {
552
+ DestinationAddress: '#' + tmpHostId,
553
+ ValueAddress: 'AppData.PromptEditorInsertPick.' + tmpPickerHash,
554
+ Placeholder: 'Insert word list…',
555
+ Options: tmpOptions,
556
+ OnChange: (pValue) =>
557
+ {
558
+ if (!pValue) { return; }
559
+ tmpSelf.insertWordList(pSegment.Key, pValue, 'pspe-seg-' + tmpSelf.Hash + '-' + pSegment.Key);
560
+ // This control is an action, not a value holder: reset to the
561
+ // placeholder once the picker finishes its own select cycle.
562
+ setTimeout(() =>
563
+ {
564
+ try { tmpPickerView.setValue(null); }
565
+ catch (pError) { /* the picker was re-rendered away; nothing to reset */ }
566
+ }, 0);
567
+ }
568
+ });
569
+ tmpPickerView.render();
570
+ });
571
+ }
572
+
573
+ // ---- wiring --------------------------------------------------------------
574
+ _initProvider()
575
+ {
576
+ if (this.options.DataProvider)
577
+ {
578
+ this._provider = this.options.DataProvider;
579
+ return;
580
+ }
581
+ if (!this.pict.AppData.PromptEditorStores) { this.pict.AppData.PromptEditorStores = {}; }
582
+ if (!this.pict.AppData.PromptEditorStores[this.Hash]) { this.pict.AppData.PromptEditorStores[this.Hash] = {}; }
583
+ this._provider = new libProvider.InMemoryPromptProvider({ Store: this.pict.AppData.PromptEditorStores[this.Hash] });
584
+ }
585
+
586
+ _ensureSupportViews()
587
+ {
588
+ // Markdown rendering for previews and the generated browser.
589
+ if (!this._contentProvider())
590
+ {
591
+ this.pict.addProvider('Pict-Content',
592
+ libPictSectionContent.PictContentProvider.default_configuration,
593
+ libPictSectionContent.PictContentProvider);
594
+ }
595
+ // Confirm dialogs, toasts, and the rich editor host. Registering is not
596
+ // enough: the modal scopes its CSS variables under a .pict-modal-root
597
+ // class it puts on <body> during onBeforeInitialize, so an uninitialized
598
+ // modal renders unstyled dialogs and toasts. Initialize it explicitly
599
+ // when the root class is absent (idempotent in hosts that already did).
600
+ if (!this.pict.views['Pict-Section-Modal'])
601
+ {
602
+ this.pict.addView('Pict-Section-Modal', libPictSectionModal.default_configuration, libPictSectionModal);
603
+ }
604
+ let tmpModalView = this.pict.views['Pict-Section-Modal'];
605
+ if (tmpModalView && typeof document !== 'undefined' && document.body
606
+ && !document.body.classList.contains('pict-modal-root')
607
+ && typeof tmpModalView.initialize === 'function')
608
+ {
609
+ tmpModalView.initialize();
610
+ }
611
+ // The searchable insert-word-list control on each segment.
612
+ if (!this.pict.providers['Pict-Section-Picker'])
613
+ {
614
+ this.pict.addProvider('Pict-Section-Picker', libPictSectionPicker.default_configuration, libPictSectionPicker);
615
+ }
616
+ // The modal's panel collapse tab resolves its chevron through the
617
+ // window.pict global (the framework convention); expose it for hosts
618
+ // that only set window._Pict, or the tab renders without a glyph.
619
+ if (typeof window !== 'undefined' && !window.pict)
620
+ {
621
+ window.pict = this.pict;
622
+ }
623
+ }
624
+
625
+ _registerTemplateExpression()
626
+ {
627
+ // Register {~WordListEntry:~} once per pict instance, then add this
628
+ // view's word lists as a resolver so the expression works in ordinary
629
+ // application templates too (not just generation runs).
630
+ if (!this.pict.__PictSectionPromptEditorTemplateRegistered && typeof this.pict.addTemplate === 'function')
631
+ {
632
+ try { this.pict.addTemplate(libWordListTemplate); this.pict.__PictSectionPromptEditorTemplateRegistered = true; }
633
+ catch (pError) { this.log.warn('PromptEditor: WordListEntry template registration failed: ' + pError.message); }
634
+ }
635
+ if (!Array.isArray(this.pict.__PictSectionPromptEditorResolvers)) { this.pict.__PictSectionPromptEditorResolvers = []; }
636
+ this.pict.__PictSectionPromptEditorResolvers.push((pNameLower) =>
637
+ {
638
+ let tmpList = (this._loaded.WordLists || []).find((pList) => String(pList.Name || '').trim().toLowerCase() === pNameLower);
639
+ return tmpList ? tmpList.Entries : null;
640
+ });
641
+ }
642
+
643
+ _contentProvider()
644
+ {
645
+ let tmpCandidates = ['Pict-Content', 'Content'];
646
+ for (let i = 0; i < tmpCandidates.length; i++)
647
+ {
648
+ let tmpProvider = this.pict.providers[tmpCandidates[i]];
649
+ if (tmpProvider && typeof tmpProvider.parseMarkdown === 'function') { return tmpProvider; }
650
+ }
651
+ return null;
652
+ }
653
+
654
+ _modal() { return this.pict.views['Pict-Section-Modal']; }
655
+
656
+ _toast(pMessage, pType)
657
+ {
658
+ let tmpModal = this._modal();
659
+ if (tmpModal && typeof tmpModal.toast === 'function') { tmpModal.toast(pMessage, { type: pType || 'info' }); }
660
+ }
661
+
662
+ _fire(pHook, pPayload)
663
+ {
664
+ if (typeof this.options[pHook] === 'function')
665
+ {
666
+ try { this.options[pHook](pPayload); } catch (pError) { /* a host hook throwing must not break the section */ }
667
+ }
668
+ if (pHook !== 'onChange' && typeof this.options.onChange === 'function')
669
+ {
670
+ try { this.options.onChange({ Event: pHook.replace(/^on/, ''), Payload: pPayload }); } catch (pError) { /* same */ }
671
+ }
672
+ }
673
+
674
+ // ---- public API ------------------------------------------------------------
675
+ load()
676
+ {
677
+ return this._provider.loadAll().then((pAll) =>
678
+ {
679
+ this._loaded = pAll;
680
+ // Keep selections honest after a reload.
681
+ if (this._ui.ActivePromptKey && !this._findPrompt(this._ui.ActivePromptKey)) { this._ui.ActivePromptKey = null; }
682
+ if (!this._ui.ActivePromptKey && this._loaded.Prompts.length) { this._ui.ActivePromptKey = this._loaded.Prompts[0].Key; }
683
+ if (this._ui.ActiveWordListKey && !this._findWordList(this._ui.ActiveWordListKey)) { this._ui.ActiveWordListKey = null; }
684
+ if (!this._ui.ActiveWordListKey && this._loaded.WordLists.length) { this._ui.ActiveWordListKey = this._loaded.WordLists[0].Key; }
685
+ if (this._ui.ActiveGeneratedKey && !this._findGenerated(this._ui.ActiveGeneratedKey)) { this._ui.ActiveGeneratedKey = null; }
686
+ if (!this._ui.ActiveGeneratedKey && this._loaded.Generated.length) { this._ui.ActiveGeneratedKey = this._loaded.Generated[0].Key; }
687
+ this._shape();
688
+ this.render();
689
+ });
690
+ }
691
+
692
+ refresh() { return this.load(); }
693
+
694
+ setReadOnly(pReadOnly)
695
+ {
696
+ this.options.ReadOnly = !!pReadOnly;
697
+ this._shape();
698
+ this.render();
699
+ }
700
+
701
+ setDataProvider(pProvider)
702
+ {
703
+ this._provider = pProvider || null;
704
+ if (!this._provider) { this._initProvider(); }
705
+ return this.load();
706
+ }
707
+
708
+ // ---- lookups ---------------------------------------------------------------
709
+ _findPrompt(pKey) { return this._loaded.Prompts.find((pPrompt) => pPrompt.Key === pKey) || null; }
710
+ _findWordList(pKey) { return this._loaded.WordLists.find((pList) => pList.Key === pKey) || null; }
711
+ _findGenerated(pKey) { return this._loaded.Generated.find((pGenerated) => pGenerated.Key === pKey) || null; }
712
+ _activePrompt() { return this._findPrompt(this._ui.ActivePromptKey); }
713
+ _activeWordList() { return this._findWordList(this._ui.ActiveWordListKey); }
714
+ _activeType() { let tmpPrompt = this._activePrompt(); return tmpPrompt ? libTypes.getPromptType(this._types, tmpPrompt.TypeKey) : null; }
715
+
716
+ _renderMarkdown(pMarkdown)
717
+ {
718
+ let tmpMarkdown = String(pMarkdown || '');
719
+ let tmpProvider = this._contentProvider();
720
+ if (tmpProvider)
721
+ {
722
+ try { return tmpProvider.parseMarkdown(tmpMarkdown, null, null, null) || ''; }
723
+ catch (pError) { /* fall through to the escaped form */ }
724
+ }
725
+ let tmpEscaped = tmpMarkdown.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
726
+ return '<pre>' + tmpEscaped + '</pre>';
727
+ }
728
+
729
+ _escapeHtml(pText)
730
+ {
731
+ return String(pText == null ? '' : pText).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
732
+ }
733
+
734
+ _wordListTotal(pList)
735
+ {
736
+ return ((pList && pList.Entries) || []).reduce((pSum, pEntry) =>
737
+ {
738
+ let tmpWeight = Number(pEntry[1]);
739
+ return pSum + ((isFinite(tmpWeight) && tmpWeight > 0) ? tmpWeight : 0);
740
+ }, 0);
741
+ }
742
+
743
+ _entryShare(pWeight, pTotal)
744
+ {
745
+ let tmpWeight = Number(pWeight);
746
+ return (pTotal > 0 && isFinite(tmpWeight) && tmpWeight > 0)
747
+ ? (Math.round((tmpWeight / pTotal) * 1000) / 10) + '%'
748
+ : '0%';
749
+ }
750
+
751
+ // Live update of the share chips and the rail summary after a weight edit,
752
+ // via targeted DOM writes. Deliberately NOT a re-render: replacing the pane
753
+ // would destroy the input mid-interaction (spinner clicks, typing focus).
754
+ _refreshShareCells()
755
+ {
756
+ let tmpList = this._activeWordList();
757
+ if (!tmpList) { return; }
758
+ let tmpEntries = tmpList.Entries || [];
759
+ let tmpTotal = this._wordListTotal(tmpList);
760
+ for (let i = 0; i < tmpEntries.length; i++)
761
+ {
762
+ this.pict.ContentAssignment.assignContent('#pspe-pct-' + this.Hash + '-' + i, this._entryShare(tmpEntries[i][1], tmpTotal));
763
+ }
764
+ this.pict.ContentAssignment.assignContent('#pspe-wlsub-' + this.Hash + '-' + tmpList.Key,
765
+ this._escapeHtml(tmpEntries.length + ' words · total weight ' + tmpTotal));
766
+ }
767
+
768
+ _scheduleEntriesSave()
769
+ {
770
+ if (this._entriesSaveTimer) { clearTimeout(this._entriesSaveTimer); }
771
+ this._entriesSaveTimer = setTimeout(() => { this._entriesSaveTimer = null; this.saveEntries(); }, 400);
772
+ }
773
+
774
+ _copyToClipboard(pText, pLabel)
775
+ {
776
+ if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText)
777
+ {
778
+ this._toast('Clipboard is not available here.', 'info');
779
+ return;
780
+ }
781
+ navigator.clipboard.writeText(String(pText == null ? '' : pText))
782
+ .then(() => this._toast((pLabel || 'Markdown') + ' copied.', 'success'))
783
+ .catch(() => this._toast('Copy failed.', 'error'));
784
+ }
785
+
786
+ _ago(pStamp)
787
+ {
788
+ let tmpDelta = Date.now() - Number(pStamp || 0);
789
+ if (!isFinite(tmpDelta) || tmpDelta < 0) { return ''; }
790
+ let tmpMinutes = Math.floor(tmpDelta / 60000);
791
+ if (tmpMinutes < 1) { return 'just now'; }
792
+ if (tmpMinutes < 60) { return tmpMinutes + 'm ago'; }
793
+ let tmpHours = Math.floor(tmpMinutes / 60);
794
+ if (tmpHours < 24) { return tmpHours + 'h ago'; }
795
+ return Math.floor(tmpHours / 24) + 'd ago';
796
+ }
797
+
798
+ // ---- shape: loaded + ui --> the render model -------------------------------
799
+ _shape()
800
+ {
801
+ let tmpReadOnly = !!this.options.ReadOnly;
802
+ let tmpState =
803
+ {
804
+ ViewHash: this.Hash,
805
+ Title: this.options.Title,
806
+ Tabs:
807
+ [
808
+ { ViewHash: this.Hash, Key: 'prompts', Label: 'Prompts', Count: this._loaded.Prompts.length, OnClass: (this._ui.Tab === 'prompts') ? 'pspe-tab-on' : '' },
809
+ { ViewHash: this.Hash, Key: 'wordlists', Label: 'Word Lists', Count: this._loaded.WordLists.length, OnClass: (this._ui.Tab === 'wordlists') ? 'pspe-tab-on' : '' },
810
+ { ViewHash: this.Hash, Key: 'generated', Label: 'Generated', Count: this._loaded.Generated.length, OnClass: (this._ui.Tab === 'generated') ? 'pspe-tab-on' : '' }
811
+ ],
812
+ PromptsPaneSlot: [],
813
+ WordListsPaneSlot: [],
814
+ GeneratedPaneSlot: []
815
+ };
816
+
817
+ if (this._ui.Tab === 'prompts') { tmpState.PromptsPaneSlot = [this._shapePromptsPane(tmpReadOnly)]; }
818
+ else if (this._ui.Tab === 'wordlists') { tmpState.WordListsPaneSlot = [this._shapeWordListsPane(tmpReadOnly)]; }
819
+ else { tmpState.GeneratedPaneSlot = [this._shapeGeneratedPane(tmpReadOnly)]; }
820
+
821
+ this._state = tmpState;
822
+ }
823
+
824
+ _shapePromptsPane(pReadOnly)
825
+ {
826
+ let tmpActive = this._activePrompt();
827
+ let tmpPane =
828
+ {
829
+ ViewHash: this.Hash,
830
+ NewSlot: pReadOnly ? [] : [{ ViewHash: this.Hash }],
831
+ Prompts: this._loaded.Prompts.map((pPrompt) => (
832
+ {
833
+ ViewHash: this.Hash,
834
+ Key: pPrompt.Key,
835
+ Title: pPrompt.Title || 'Untitled prompt',
836
+ TypeName: libTypes.getPromptType(this._types, pPrompt.TypeKey).Name + ' · ' + this._ago(pPrompt.UpdatedAt),
837
+ OnClass: (tmpActive && tmpActive.Key === pPrompt.Key) ? 'pspe-rail-item-on' : ''
838
+ })),
839
+ EmptySlot: this._loaded.Prompts.length ? [] : [{ Text: 'No prompts yet.' }],
840
+ EditorSlot: [],
841
+ NoSelectionSlot: []
842
+ };
843
+ if (tmpActive) { tmpPane.EditorSlot = [this._shapePromptEditor(tmpActive, pReadOnly)]; }
844
+ else { tmpPane.NoSelectionSlot = [{ Text: pReadOnly ? 'Nothing to show.' : 'Create a prompt to start crafting.' }]; }
845
+ return tmpPane;
846
+ }
847
+
848
+ _shapePromptEditor(pPrompt, pReadOnly)
849
+ {
850
+ let tmpType = libTypes.getPromptType(this._types, pPrompt.TypeKey);
851
+ let tmpHasRichEditor = !pReadOnly && !!(this.options.CodeMirrorModules || (typeof window !== 'undefined' && window.CodeMirrorModules));
852
+
853
+ let tmpSegments = (tmpType.Segments || []).map((pSegment) =>
854
+ {
855
+ let tmpBody = String((pPrompt.Segments || {})[pSegment.Key] || '');
856
+ let tmpPreviewKey = pPrompt.Key + ':' + pSegment.Key;
857
+ let tmpPreviewOn = !!this._ui.PreviewSegments[tmpPreviewKey];
858
+ let tmpPills = [];
859
+ if (pSegment.Fixed) { tmpPills.push({ Label: 'fixed' }); }
860
+ if (pSegment.Optional) { tmpPills.push({ Label: 'optional' }); }
861
+ let tmpShaped =
862
+ {
863
+ ViewHash: this.Hash,
864
+ SegmentKey: pSegment.Key,
865
+ Name: pSegment.Name || pSegment.Key,
866
+ Guidance: pSegment.Guidance || '',
867
+ PillSlot: tmpPills,
868
+ FixedSlot: [],
869
+ EditSlot: [],
870
+ PreviewSlot: []
871
+ };
872
+ if (pSegment.Fixed)
873
+ {
874
+ tmpShaped.FixedSlot = [{ BodyHtml: this._renderMarkdown(pSegment.Body || '') }];
875
+ return tmpShaped;
876
+ }
877
+ if (pReadOnly)
878
+ {
879
+ tmpShaped.PreviewSlot = [{ BodyHtml: this._renderMarkdown(tmpBody || '*(empty)*') }];
880
+ return tmpShaped;
881
+ }
882
+ tmpShaped.EditSlot =
883
+ [{
884
+ ViewHash: this.Hash,
885
+ SegmentKey: pSegment.Key,
886
+ TextareaId: 'pspe-seg-' + this.Hash + '-' + pSegment.Key,
887
+ Body: tmpBody,
888
+ Placeholder: 'Markdown. Pict template expressions welcome, like {~WordListEntry:ListName~}.',
889
+ PreviewLabel: tmpPreviewOn ? 'Hide preview' : 'Preview',
890
+ RichSlot: tmpHasRichEditor ? [{ ViewHash: this.Hash, SegmentKey: pSegment.Key }] : []
891
+ }];
892
+ if (tmpPreviewOn)
893
+ {
894
+ tmpShaped.PreviewSlot = [{ BodyHtml: this._renderMarkdown(tmpBody || '*(empty)*') }];
895
+ }
896
+ return tmpShaped;
897
+ });
898
+
899
+ let tmpGeneratedForPrompt = this._loaded.Generated.filter((pGenerated) => pGenerated.PromptKey === pPrompt.Key).length;
900
+ return (
901
+ {
902
+ ViewHash: this.Hash,
903
+ Title: pPrompt.Title || '',
904
+ TypeDescription: tmpType.Description || '',
905
+ TypeOptions: this._types.map((pTypeOption) => (
906
+ {
907
+ Value: pTypeOption.Key,
908
+ Label: pTypeOption.Name,
909
+ SelectedAttr: (pTypeOption.Key === pPrompt.TypeKey) ? 'selected' : ''
910
+ })),
911
+ ActionsSlot: pReadOnly ? [] : [{ ViewHash: this.Hash }],
912
+ Segments: tmpSegments,
913
+ GenerateSlot: pReadOnly ? [] :
914
+ [{
915
+ ViewHash: this.Hash,
916
+ Count: this._ui.GenerateCount,
917
+ MaxCount: this.options.GenerateMaxCount,
918
+ Note: tmpGeneratedForPrompt ? (tmpGeneratedForPrompt + ' generated from this prompt so far') : 'Each generation rolls the word lists fresh.'
919
+ }],
920
+ PreviewPanelSlot: (this._ui.Preview && this._ui.Preview.PromptKey === pPrompt.Key)
921
+ ? [{
922
+ ViewHash: this.Hash,
923
+ FormattedOnClass: (this._ui.PreviewMode !== 'raw') ? ' pspe-btn-on' : '',
924
+ RawOnClass: (this._ui.PreviewMode === 'raw') ? ' pspe-btn-on' : '',
925
+ RenderedSlot: (this._ui.PreviewMode !== 'raw') ? [{ MarkdownHtml: this._renderMarkdown(this._ui.Preview.Markdown) }] : [],
926
+ RawSlot: (this._ui.PreviewMode === 'raw') ? [{ MarkdownEscaped: this._escapeHtml(this._ui.Preview.Markdown) }] : []
927
+ }]
928
+ : []
929
+ });
930
+ }
931
+
932
+ _shapeWordListsPane(pReadOnly)
933
+ {
934
+ let tmpActive = this._activeWordList();
935
+ let tmpPane =
936
+ {
937
+ ViewHash: this.Hash,
938
+ NewSlot: pReadOnly ? [] : [{ ViewHash: this.Hash }],
939
+ WordLists: this._loaded.WordLists.map((pList) => (
940
+ {
941
+ ViewHash: this.Hash,
942
+ Key: pList.Key,
943
+ Name: pList.Name,
944
+ Summary: (pList.Entries || []).length + ' words · total weight ' + this._wordListTotal(pList),
945
+ OnClass: (tmpActive && tmpActive.Key === pList.Key) ? 'pspe-rail-item-on' : ''
946
+ })),
947
+ EmptySlot: this._loaded.WordLists.length ? [] : [{ Text: 'No word lists yet.' }],
948
+ DetailSlot: [],
949
+ NoSelectionSlot: []
950
+ };
951
+ if (tmpActive && !pReadOnly)
952
+ {
953
+ let tmpTotal = this._wordListTotal(tmpActive);
954
+ tmpPane.DetailSlot =
955
+ [{
956
+ ViewHash: this.Hash,
957
+ Key: tmpActive.Key,
958
+ Name: tmpActive.Name,
959
+ ExpressionExample: '{~WordListEntry:' + tmpActive.Name + '~}',
960
+ Entries: (tmpActive.Entries || []).map((pEntry, pIndex) => (
961
+ {
962
+ ViewHash: this.Hash,
963
+ Index: pIndex,
964
+ Word: pEntry[0],
965
+ Weight: pEntry[1],
966
+ Percent: this._entryShare(pEntry[1], tmpTotal)
967
+ }))
968
+ }];
969
+ }
970
+ else if (tmpActive && pReadOnly)
971
+ {
972
+ tmpPane.NoSelectionSlot = [{ Text: tmpActive.Name + ': ' + (tmpActive.Entries || []).map((pEntry) => pEntry[0] + ' (' + pEntry[1] + ')').join(', ') }];
973
+ }
974
+ else
975
+ {
976
+ tmpPane.NoSelectionSlot = [{ Text: pReadOnly ? 'Nothing to show.' : 'Create a word list to template against.' }];
977
+ }
978
+ return tmpPane;
979
+ }
980
+
981
+ _shapeGeneratedPane(pReadOnly)
982
+ {
983
+ let tmpActive = this._findGenerated(this._ui.ActiveGeneratedKey);
984
+ let tmpPane =
985
+ {
986
+ ViewHash: this.Hash,
987
+ Note: this._loaded.Generated.length
988
+ ? (this._loaded.Generated.length + ' generated prompt' + (this._loaded.Generated.length === 1 ? '' : 's') + ', in file order. The zip holds them all as markdown files.')
989
+ : 'Generate from a prompt and the output lands here.',
990
+ ToolsSlot: (this._loaded.Generated.length && !pReadOnly) ? [{ ViewHash: this.Hash }] : [],
991
+ Generated: this._loaded.Generated.map((pGenerated) => (
992
+ {
993
+ ViewHash: this.Hash,
994
+ Key: pGenerated.Key,
995
+ FileName: libCompiler.generatedFileName(pGenerated),
996
+ Sub: (pGenerated.PromptTitle || 'untitled') + ' · ' + this._ago(pGenerated.GeneratedAt),
997
+ OnClass: (tmpActive && tmpActive.Key === pGenerated.Key) ? 'pspe-rail-item-on' : ''
998
+ })),
999
+ EmptySlot: this._loaded.Generated.length ? [] : [{ Text: 'Nothing generated yet.' }],
1000
+ ViewerSlot: [],
1001
+ NoSelectionSlot: []
1002
+ };
1003
+ if (tmpActive)
1004
+ {
1005
+ tmpPane.ViewerSlot = [{ ViewHash: this.Hash, MarkdownHtml: this._renderMarkdown(tmpActive.Markdown) }];
1006
+ }
1007
+ else if (this._loaded.Generated.length)
1008
+ {
1009
+ tmpPane.NoSelectionSlot = [{ Text: 'Pick a generated prompt to read it.' }];
1010
+ }
1011
+ return tmpPane;
1012
+ }
1013
+
1014
+ _reshape() { this._shape(); this.render(); }
1015
+
1016
+ // ---- tab + selection handlers ------------------------------------------------
1017
+ selectTab(pTab) { this._ui.Tab = pTab; this._reshape(); }
1018
+ selectPrompt(pKey)
1019
+ {
1020
+ if (this._ui.ActivePromptKey !== pKey) { this._ui.Preview = null; }
1021
+ this._ui.ActivePromptKey = pKey;
1022
+ this._reshape();
1023
+ }
1024
+ selectWordList(pKey) { this._ui.ActiveWordListKey = pKey; this._reshape(); }
1025
+ selectGenerated(pKey) { this._ui.ActiveGeneratedKey = pKey; this._reshape(); }
1026
+
1027
+ // ---- prompt handlers -----------------------------------------------------------
1028
+ newPrompt()
1029
+ {
1030
+ let tmpTypeKey = this._types.length ? this._types[0].Key : 'freeform';
1031
+ this._provider.createPrompt({ TypeKey: tmpTypeKey, Title: 'Untitled prompt', Author: this.options.CurrentUser })
1032
+ .then((pPrompt) =>
1033
+ {
1034
+ this._ui.ActivePromptKey = pPrompt.Key;
1035
+ this._fire('onPromptSaved', pPrompt);
1036
+ return this.load();
1037
+ })
1038
+ .catch((pError) => this._toast('Could not create the prompt: ' + pError.message, 'error'));
1039
+ }
1040
+
1041
+ cachePromptTitle(pValue)
1042
+ {
1043
+ let tmpPrompt = this._activePrompt();
1044
+ if (!tmpPrompt) { return; }
1045
+ tmpPrompt.Title = pValue;
1046
+ // Mirror into the rail in place; no re-render while typing.
1047
+ this.pict.ContentAssignment.assignContent('#pspe-prname-' + this.Hash + '-' + tmpPrompt.Key, this._escapeHtml(pValue || 'Untitled prompt'));
1048
+ }
1049
+
1050
+ savePromptTitle()
1051
+ {
1052
+ let tmpPrompt = this._activePrompt();
1053
+ if (!tmpPrompt) { return; }
1054
+ this._provider.updatePrompt(tmpPrompt.Key, { Title: tmpPrompt.Title })
1055
+ .then((pSaved) => { this._fire('onPromptSaved', pSaved); })
1056
+ .catch((pError) => this._toast('Title save failed: ' + pError.message, 'error'));
1057
+ }
1058
+
1059
+ setPromptType(pTypeKey)
1060
+ {
1061
+ let tmpPrompt = this._activePrompt();
1062
+ if (!tmpPrompt) { return; }
1063
+ // Non-destructive: segment bodies stay keyed in the prompt; switching
1064
+ // types changes which segments show and compile.
1065
+ tmpPrompt.TypeKey = pTypeKey;
1066
+ this._provider.updatePrompt(tmpPrompt.Key, { TypeKey: pTypeKey })
1067
+ .then((pSaved) => { this._fire('onPromptSaved', pSaved); this._reshape(); })
1068
+ .catch((pError) => this._toast('Type change failed: ' + pError.message, 'error'));
1069
+ }
1070
+
1071
+ duplicatePrompt()
1072
+ {
1073
+ let tmpPrompt = this._activePrompt();
1074
+ if (!tmpPrompt) { return; }
1075
+ this._provider.createPrompt(
1076
+ {
1077
+ TypeKey: tmpPrompt.TypeKey,
1078
+ Title: tmpPrompt.Title + ' (copy)',
1079
+ Segments: JSON.parse(JSON.stringify(tmpPrompt.Segments || {})),
1080
+ Meta: JSON.parse(JSON.stringify(tmpPrompt.Meta || {})),
1081
+ Author: this.options.CurrentUser
1082
+ })
1083
+ .then((pCopy) =>
1084
+ {
1085
+ this._ui.ActivePromptKey = pCopy.Key;
1086
+ this._fire('onPromptSaved', pCopy);
1087
+ return this.load();
1088
+ })
1089
+ .catch((pError) => this._toast('Duplicate failed: ' + pError.message, 'error'));
1090
+ }
1091
+
1092
+ deletePrompt()
1093
+ {
1094
+ let tmpPrompt = this._activePrompt();
1095
+ if (!tmpPrompt) { return; }
1096
+ let tmpModal = this._modal();
1097
+ let fDelete = () => this._provider.deletePrompt(tmpPrompt.Key)
1098
+ .then(() => { this._fire('onPromptDeleted', tmpPrompt); this._ui.ActivePromptKey = null; return this.load(); })
1099
+ .catch((pError) => this._toast('Delete failed: ' + pError.message, 'error'));
1100
+ if (tmpModal && typeof tmpModal.confirm === 'function')
1101
+ {
1102
+ tmpModal.confirm('Delete "' + (tmpPrompt.Title || 'this prompt') + '" and everything generated from it?', { title: 'Delete prompt?', confirmLabel: 'Delete', dangerous: true })
1103
+ .then((pOk) => { if (pOk) { fDelete(); } });
1104
+ }
1105
+ else { fDelete(); }
1106
+ }
1107
+
1108
+ // ---- segment handlers ----------------------------------------------------------
1109
+ cacheSegment(pSegmentKey, pValue)
1110
+ {
1111
+ let tmpPrompt = this._activePrompt();
1112
+ if (!tmpPrompt) { return; }
1113
+ if (!tmpPrompt.Segments) { tmpPrompt.Segments = {}; }
1114
+ tmpPrompt.Segments[pSegmentKey] = pValue;
1115
+ }
1116
+
1117
+ saveSegment(pSegmentKey)
1118
+ {
1119
+ let tmpPrompt = this._activePrompt();
1120
+ if (!tmpPrompt) { return; }
1121
+ this._provider.updatePrompt(tmpPrompt.Key, { Segments: tmpPrompt.Segments })
1122
+ .then((pSaved) => { this._fire('onPromptSaved', pSaved); })
1123
+ .catch((pError) => this._toast('Segment save failed: ' + pError.message, 'error'));
1124
+ }
1125
+
1126
+ toggleSegmentPreview(pSegmentKey)
1127
+ {
1128
+ let tmpPrompt = this._activePrompt();
1129
+ if (!tmpPrompt) { return; }
1130
+ let tmpPreviewKey = tmpPrompt.Key + ':' + pSegmentKey;
1131
+ this._ui.PreviewSegments[tmpPreviewKey] = !this._ui.PreviewSegments[tmpPreviewKey];
1132
+ this._reshape();
1133
+ }
1134
+
1135
+ // Insert {~WordListEntry:Name~} at the caret of the segment's textarea.
1136
+ insertWordList(pSegmentKey, pListName, pTextareaId)
1137
+ {
1138
+ if (!pListName) { return; }
1139
+ let tmpExpression = '{~WordListEntry:' + pListName + '~}';
1140
+ let tmpTextarea = (typeof document !== 'undefined') ? document.getElementById(pTextareaId) : null;
1141
+ if (tmpTextarea)
1142
+ {
1143
+ let tmpStart = (typeof tmpTextarea.selectionStart === 'number') ? tmpTextarea.selectionStart : tmpTextarea.value.length;
1144
+ let tmpEnd = (typeof tmpTextarea.selectionEnd === 'number') ? tmpTextarea.selectionEnd : tmpStart;
1145
+ tmpTextarea.value = tmpTextarea.value.slice(0, tmpStart) + tmpExpression + tmpTextarea.value.slice(tmpEnd);
1146
+ tmpTextarea.focus();
1147
+ tmpTextarea.selectionStart = tmpTextarea.selectionEnd = tmpStart + tmpExpression.length;
1148
+ this.cacheSegment(pSegmentKey, tmpTextarea.value);
1149
+ }
1150
+ else
1151
+ {
1152
+ let tmpPrompt = this._activePrompt();
1153
+ if (!tmpPrompt) { return; }
1154
+ this.cacheSegment(pSegmentKey, String((tmpPrompt.Segments || {})[pSegmentKey] || '') + tmpExpression);
1155
+ }
1156
+ this.saveSegment(pSegmentKey);
1157
+ }
1158
+
1159
+ // The full pict-section-markdowneditor, hosted in a modal, editing one segment.
1160
+ openRichEditor(pSegmentKey)
1161
+ {
1162
+ let tmpPrompt = this._activePrompt();
1163
+ let tmpModal = this._modal();
1164
+ if (!tmpPrompt || !tmpModal || typeof tmpModal.show !== 'function') { return; }
1165
+ let tmpModules = this.options.CodeMirrorModules || ((typeof window !== 'undefined') ? window.CodeMirrorModules : null);
1166
+ if (!tmpModules)
1167
+ {
1168
+ this._toast('The rich editor needs CodeMirror; the inline editor still works.', 'info');
1169
+ return;
1170
+ }
1171
+
1172
+ // The rich editor reads segments from a fable-manifest address; keep it
1173
+ // bracket-free by keying a dedicated AppData node on a sanitized hash.
1174
+ let tmpRichKey = 'PromptEditorRich_' + String(this.Hash).replace(/[^A-Za-z0-9_]/g, '_');
1175
+ if (!this.pict.AppData[tmpRichKey]) { this.pict.AppData[tmpRichKey] = {}; }
1176
+ this.pict.AppData[tmpRichKey].Segments = [{ Content: String((tmpPrompt.Segments || {})[pSegmentKey] || '') }];
1177
+ this._richDataKey = tmpRichKey;
1178
+
1179
+ if (!this._richEditorViewHash)
1180
+ {
1181
+ this._richEditorViewHash = 'PromptEditor-MDE-' + this.Hash;
1182
+ this.pict.addView(this._richEditorViewHash, Object.assign({}, libMarkdownEditor.default_configuration,
1183
+ {
1184
+ ViewIdentifier: this._richEditorViewHash,
1185
+ AutoRender: false,
1186
+ DefaultDestinationAddress: '#pspe-rich-host-' + this.Hash,
1187
+ // The editor builds its UI into TargetElementAddress directly
1188
+ // (separate from the renderable destination).
1189
+ TargetElementAddress: '#pspe-rich-host-' + this.Hash,
1190
+ ContentDataAddress: 'AppData.' + tmpRichKey + '.Segments',
1191
+ DefaultPreviewMode: 'side'
1192
+ }), libMarkdownEditor);
1193
+ }
1194
+
1195
+ let tmpEditor = this.pict.views[this._richEditorViewHash];
1196
+ let tmpSelf = this;
1197
+ tmpModal.show(
1198
+ {
1199
+ title: 'Edit segment',
1200
+ content: '<div class="pspe-rich-host" id="pspe-rich-host-' + this.Hash + '"></div>',
1201
+ width: '860px',
1202
+ buttons:
1203
+ [
1204
+ { Hash: 'cancel', Label: 'Cancel' },
1205
+ { Hash: 'save', Label: 'Save', Style: 'primary' }
1206
+ ],
1207
+ onOpen: function ()
1208
+ {
1209
+ try
1210
+ {
1211
+ tmpEditor.connectCodeMirrorModules(tmpModules);
1212
+ tmpEditor.render();
1213
+ tmpEditor.marshalToView();
1214
+ }
1215
+ catch (pError) { tmpSelf._toast('Rich editor failed to start: ' + pError.message, 'error'); }
1216
+ }
1217
+ }).then((pChoice) =>
1218
+ {
1219
+ if (pChoice !== 'save') { return; }
1220
+ try { tmpEditor.marshalFromView(); } catch (pError) { /* fall back to whatever is in AppData */ }
1221
+ let tmpSegments = (tmpSelf.pict.AppData[tmpSelf._richDataKey] || {}).Segments || [];
1222
+ let tmpContent = tmpSegments.map((pSegment) => String(pSegment.Content || '')).join('\n\n');
1223
+ tmpSelf.cacheSegment(pSegmentKey, tmpContent);
1224
+ tmpSelf.saveSegment(pSegmentKey);
1225
+ tmpSelf._reshape();
1226
+ });
1227
+ }
1228
+
1229
+ // ---- generation -------------------------------------------------------------------
1230
+ setGenerateCount(pValue)
1231
+ {
1232
+ let tmpCount = Math.max(1, Math.min(this.options.GenerateMaxCount, Number(pValue) || 1));
1233
+ this._ui.GenerateCount = tmpCount;
1234
+ }
1235
+
1236
+ _generateOnce(pPrompt, pType)
1237
+ {
1238
+ return libCompiler.generate(this.pict, pPrompt, pType, this._loaded.WordLists,
1239
+ {
1240
+ IncludeTitleHeading: this.options.IncludeTitleHeading,
1241
+ SegmentHeadingLevel: this.options.SegmentHeadingLevel,
1242
+ RandomFunction: (typeof this.options.RandomFunction === 'function') ? this.options.RandomFunction : undefined
1243
+ });
1244
+ }
1245
+
1246
+ generatePrompts()
1247
+ {
1248
+ let tmpPrompt = this._activePrompt();
1249
+ if (!tmpPrompt) { return; }
1250
+ let tmpType = libTypes.getPromptType(this._types, tmpPrompt.TypeKey);
1251
+ let tmpCount = this._ui.GenerateCount;
1252
+ let tmpExisting = this._loaded.Generated.filter((pGenerated) => pGenerated.PromptKey === tmpPrompt.Key).length;
1253
+
1254
+ let tmpCreates = [];
1255
+ for (let i = 0; i < tmpCount; i++)
1256
+ {
1257
+ tmpCreates.push(this._provider.createGenerated(
1258
+ {
1259
+ PromptKey: tmpPrompt.Key,
1260
+ PromptTitle: tmpPrompt.Title,
1261
+ TypeKey: tmpPrompt.TypeKey,
1262
+ Markdown: this._generateOnce(tmpPrompt, tmpType),
1263
+ Sequence: tmpExisting + i + 1,
1264
+ Author: this.options.CurrentUser
1265
+ }));
1266
+ }
1267
+ Promise.all(tmpCreates)
1268
+ .then((pBatch) =>
1269
+ {
1270
+ this._fire('onGenerated', pBatch);
1271
+ this._toast('Generated ' + pBatch.length + ' prompt' + (pBatch.length === 1 ? '' : 's') + '.', 'success');
1272
+ this._ui.Tab = 'generated';
1273
+ if (pBatch.length) { this._ui.ActiveGeneratedKey = pBatch[0].Key; }
1274
+ return this.load();
1275
+ })
1276
+ .catch((pError) => this._toast('Generation failed: ' + pError.message, 'error'));
1277
+ }
1278
+
1279
+ // Roll the active prompt once, without saving, into the preview panel below
1280
+ // the editor. Rolling again replaces it; closing clears it; switching
1281
+ // prompts drops it.
1282
+ previewOnce()
1283
+ {
1284
+ let tmpPrompt = this._activePrompt();
1285
+ if (!tmpPrompt) { return; }
1286
+ let tmpType = libTypes.getPromptType(this._types, tmpPrompt.TypeKey);
1287
+ this._ui.Preview = { PromptKey: tmpPrompt.Key, Markdown: this._generateOnce(tmpPrompt, tmpType) };
1288
+ this._reshape();
1289
+ }
1290
+
1291
+ closePreview()
1292
+ {
1293
+ this._ui.Preview = null;
1294
+ this._reshape();
1295
+ }
1296
+
1297
+ // 'rendered' shows the formatted markdown; 'raw' shows the source. The
1298
+ // choice is sticky for the session.
1299
+ setPreviewMode(pMode)
1300
+ {
1301
+ this._ui.PreviewMode = (pMode === 'raw') ? 'raw' : 'rendered';
1302
+ this._reshape();
1303
+ }
1304
+
1305
+ copyPreview()
1306
+ {
1307
+ if (!this._ui.Preview) { return; }
1308
+ this._copyToClipboard(this._ui.Preview.Markdown, 'Prompt');
1309
+ }
1310
+
1311
+ // ---- word list handlers ----------------------------------------------------------
1312
+ newWordList()
1313
+ {
1314
+ let tmpBase = 'New word list';
1315
+ let tmpName = tmpBase;
1316
+ let tmpNumber = 2;
1317
+ while (this._loaded.WordLists.some((pList) => pList.Name === tmpName)) { tmpName = tmpBase + ' ' + tmpNumber++; }
1318
+ this._provider.createWordList({ Name: tmpName, Entries: [['', 1]] })
1319
+ .then((pList) =>
1320
+ {
1321
+ this._ui.ActiveWordListKey = pList.Key;
1322
+ this._ui.Tab = 'wordlists';
1323
+ this._fire('onWordListSaved', pList);
1324
+ return this.load();
1325
+ })
1326
+ .catch((pError) => this._toast('Could not create the word list: ' + pError.message, 'error'));
1327
+ }
1328
+
1329
+ cacheWordListName(pValue)
1330
+ {
1331
+ let tmpList = this._activeWordList();
1332
+ if (!tmpList) { return; }
1333
+ tmpList.Name = pValue;
1334
+ // Mirror into the rail in place; no re-render while typing.
1335
+ this.pict.ContentAssignment.assignContent('#pspe-wlname-' + this.Hash + '-' + tmpList.Key, this._escapeHtml(pValue));
1336
+ }
1337
+
1338
+ saveWordListName()
1339
+ {
1340
+ let tmpList = this._activeWordList();
1341
+ if (!tmpList) { return; }
1342
+ this._provider.updateWordList(tmpList.Key, { Name: tmpList.Name })
1343
+ .then((pSaved) => { this._fire('onWordListSaved', pSaved); })
1344
+ .catch((pError) => this._toast('Rename failed: ' + pError.message, 'error'));
1345
+ }
1346
+
1347
+ deleteWordList()
1348
+ {
1349
+ let tmpList = this._activeWordList();
1350
+ if (!tmpList) { return; }
1351
+ let tmpModal = this._modal();
1352
+ let fDelete = () => this._provider.deleteWordList(tmpList.Key)
1353
+ .then(() => { this._fire('onWordListDeleted', tmpList); this._ui.ActiveWordListKey = null; return this.load(); })
1354
+ .catch((pError) => this._toast('Delete failed: ' + pError.message, 'error'));
1355
+ if (tmpModal && typeof tmpModal.confirm === 'function')
1356
+ {
1357
+ tmpModal.confirm('Delete the word list "' + tmpList.Name + '"? Prompts referencing it will show the expression unresolved.', { title: 'Delete word list?', confirmLabel: 'Delete', dangerous: true })
1358
+ .then((pOk) => { if (pOk) { fDelete(); } });
1359
+ }
1360
+ else { fDelete(); }
1361
+ }
1362
+
1363
+ // Value edits never re-render: a render replaces the DOM and destroys the
1364
+ // input mid-interaction (number spinners fire change on every click, so a
1365
+ // render-on-save makes the arrows unusable). Cache + targeted share refresh
1366
+ // on input, debounced persist; structural changes (add/remove) re-render.
1367
+ cacheEntryWord(pIndex, pValue)
1368
+ {
1369
+ let tmpList = this._activeWordList();
1370
+ if (tmpList && tmpList.Entries[pIndex]) { tmpList.Entries[pIndex][0] = pValue; }
1371
+ this._scheduleEntriesSave();
1372
+ }
1373
+
1374
+ cacheEntryWeight(pIndex, pValue)
1375
+ {
1376
+ let tmpList = this._activeWordList();
1377
+ if (tmpList && tmpList.Entries[pIndex]) { tmpList.Entries[pIndex][1] = pValue; }
1378
+ this._refreshShareCells();
1379
+ this._scheduleEntriesSave();
1380
+ }
1381
+
1382
+ saveEntries()
1383
+ {
1384
+ if (this._entriesSaveTimer) { clearTimeout(this._entriesSaveTimer); this._entriesSaveTimer = null; }
1385
+ let tmpList = this._activeWordList();
1386
+ if (!tmpList) { return Promise.resolve(); }
1387
+ return this._provider.updateWordList(tmpList.Key, { Entries: tmpList.Entries })
1388
+ .then((pSaved) =>
1389
+ {
1390
+ // Adopt the normalized entries (weights coerced) and refresh the
1391
+ // shares in place. The inputs themselves are left alone so typing
1392
+ // in progress is never clobbered.
1393
+ tmpList.Entries = pSaved.Entries;
1394
+ this._fire('onWordListSaved', pSaved);
1395
+ this._refreshShareCells();
1396
+ })
1397
+ .catch((pError) => this._toast('Save failed: ' + pError.message, 'error'));
1398
+ }
1399
+
1400
+ addWordListEntry()
1401
+ {
1402
+ let tmpList = this._activeWordList();
1403
+ if (!tmpList) { return; }
1404
+ tmpList.Entries.push(['', 1]);
1405
+ this._reshape();
1406
+ this.saveEntries();
1407
+ }
1408
+
1409
+ removeWordListEntry(pIndex)
1410
+ {
1411
+ let tmpList = this._activeWordList();
1412
+ if (!tmpList) { return; }
1413
+ tmpList.Entries.splice(pIndex, 1);
1414
+ this._reshape();
1415
+ this.saveEntries();
1416
+ }
1417
+
1418
+ // ---- generated handlers -------------------------------------------------------------
1419
+ deleteGenerated()
1420
+ {
1421
+ let tmpGenerated = this._findGenerated(this._ui.ActiveGeneratedKey);
1422
+ if (!tmpGenerated) { return; }
1423
+ this._provider.deleteGenerated(tmpGenerated.Key)
1424
+ .then(() => { this._ui.ActiveGeneratedKey = null; return this.load(); })
1425
+ .catch((pError) => this._toast('Delete failed: ' + pError.message, 'error'));
1426
+ }
1427
+
1428
+ clearGenerated()
1429
+ {
1430
+ let tmpModal = this._modal();
1431
+ let fClear = () => this._provider.clearGenerated()
1432
+ .then(() => { this._ui.ActiveGeneratedKey = null; return this.load(); })
1433
+ .catch((pError) => this._toast('Clear failed: ' + pError.message, 'error'));
1434
+ if (tmpModal && typeof tmpModal.confirm === 'function')
1435
+ {
1436
+ tmpModal.confirm('Remove every generated prompt? The source prompts stay.', { title: 'Clear generated?', confirmLabel: 'Clear', dangerous: true })
1437
+ .then((pOk) => { if (pOk) { fClear(); } });
1438
+ }
1439
+ else { fClear(); }
1440
+ }
1441
+
1442
+ copyGenerated()
1443
+ {
1444
+ let tmpGenerated = this._findGenerated(this._ui.ActiveGeneratedKey);
1445
+ if (!tmpGenerated) { return; }
1446
+ this._copyToClipboard(tmpGenerated.Markdown, 'Markdown');
1447
+ }
1448
+
1449
+ downloadZip()
1450
+ {
1451
+ let tmpFiles = this._loaded.Generated.map((pGenerated) => (
1452
+ {
1453
+ Name: libCompiler.generatedFileName(pGenerated),
1454
+ Content: pGenerated.Markdown
1455
+ }));
1456
+ if (!tmpFiles.length) { this._toast('Nothing generated yet.', 'info'); return; }
1457
+ libZip.buildZip(tmpFiles)
1458
+ .then((pBlob) => libZip.downloadBlob(pBlob, this.options.ZipFileName))
1459
+ .then(() => this._toast('Zip on the way: ' + tmpFiles.length + ' file' + (tmpFiles.length === 1 ? '' : 's') + '.', 'success'))
1460
+ .catch((pError) => this._toast('Zip failed: ' + pError.message, 'error'));
1461
+ }
1462
+ }
1463
+
1464
+ module.exports = PictViewPromptEditor;
1465
+ module.exports.default_configuration = _DefaultConfiguration;