retold-content-system 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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/build-codejar-bundle.js +29 -0
  4. package/build-codemirror-bundle.js +29 -0
  5. package/codejar-entry.js +10 -0
  6. package/codemirror-entry.js +16 -0
  7. package/content/Dogs.txt.md +2 -0
  8. package/content/README.md +35 -0
  9. package/content/_sidebar.md +3 -0
  10. package/content/_topbar.md +1 -0
  11. package/content/cover.md +12 -0
  12. package/content/getting-started.md +73 -0
  13. package/css/content-system.css +42 -0
  14. package/css/github.css +118 -0
  15. package/docs/.nojekyll +0 -0
  16. package/docs/README.md +24 -0
  17. package/docs/_sidebar.md +16 -0
  18. package/docs/_topbar.md +6 -0
  19. package/docs/cli.md +119 -0
  20. package/docs/cover.md +16 -0
  21. package/docs/css/docuserve.css +73 -0
  22. package/docs/editor-guide.md +137 -0
  23. package/docs/getting-started.md +73 -0
  24. package/docs/index.html +39 -0
  25. package/docs/keyboard-shortcuts.md +40 -0
  26. package/docs/retold-catalog.json +81 -0
  27. package/docs/retold-keyword-index.json +19 -0
  28. package/docs/topics.md +83 -0
  29. package/html/codejar-bundle.js +16 -0
  30. package/html/codemirror-bundle.js +29982 -0
  31. package/html/edit.html +25 -0
  32. package/html/index.html +25 -0
  33. package/html/preview.html +19 -0
  34. package/package.json +70 -0
  35. package/server.js +43 -0
  36. package/source/Pict-Application-ContentEditor-Configuration.json +15 -0
  37. package/source/Pict-Application-ContentEditor.js +1361 -0
  38. package/source/Pict-Application-ContentReader-Configuration.json +15 -0
  39. package/source/Pict-Application-ContentReader.js +91 -0
  40. package/source/Pict-ContentSystem-Bundle.js +21 -0
  41. package/source/cli/ContentSystem-CLI-Program.js +15 -0
  42. package/source/cli/ContentSystem-CLI-Run.js +3 -0
  43. package/source/cli/ContentSystem-Server-Setup.js +405 -0
  44. package/source/cli/commands/ContentSystem-Command-Serve.js +104 -0
  45. package/source/providers/Pict-Provider-ContentEditor.js +198 -0
  46. package/source/views/PictView-Editor-CodeEditor.js +271 -0
  47. package/source/views/PictView-Editor-Layout.js +1194 -0
  48. package/source/views/PictView-Editor-MarkdownEditor.js +115 -0
  49. package/source/views/PictView-Editor-MarkdownReference.js +801 -0
  50. package/source/views/PictView-Editor-SettingsPanel.js +563 -0
  51. package/source/views/PictView-Editor-TopBar.js +366 -0
  52. package/source/views/PictView-Editor-Topics.js +1025 -0
@@ -0,0 +1,1025 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ const _ViewConfiguration =
4
+ {
5
+ ViewIdentifier: "ContentEditor-Topics",
6
+
7
+ DefaultRenderable: "Topics-Wrap",
8
+ DefaultDestinationAddress: "#ContentEditor-SidebarTopics-Container",
9
+
10
+ AutoRender: false,
11
+
12
+ CSS: /*css*/`
13
+ .topics-container
14
+ {
15
+ display: flex;
16
+ flex-direction: column;
17
+ height: 100%;
18
+ font-size: 0.82rem;
19
+ color: #3D3229;
20
+ }
21
+ .topics-header
22
+ {
23
+ display: flex;
24
+ align-items: center;
25
+ gap: 6px;
26
+ padding: 8px 10px;
27
+ border-bottom: 1px solid #EDE9E3;
28
+ background: #FAF8F4;
29
+ flex-shrink: 0;
30
+ }
31
+ .topics-header-title
32
+ {
33
+ flex: 1;
34
+ font-weight: 600;
35
+ font-size: 0.78rem;
36
+ color: #5E5549;
37
+ white-space: nowrap;
38
+ overflow: hidden;
39
+ text-overflow: ellipsis;
40
+ }
41
+ .topics-header-btn
42
+ {
43
+ background: transparent;
44
+ border: none;
45
+ cursor: pointer;
46
+ font-size: 0.82rem;
47
+ color: #8A7F72;
48
+ padding: 2px 6px;
49
+ border-radius: 3px;
50
+ line-height: 1;
51
+ }
52
+ .topics-header-btn:hover
53
+ {
54
+ color: #3D3229;
55
+ background: #EDE9E3;
56
+ }
57
+ .topics-list
58
+ {
59
+ flex: 1;
60
+ overflow-y: auto;
61
+ overflow-x: hidden;
62
+ }
63
+ .topics-row
64
+ {
65
+ display: flex;
66
+ align-items: flex-start;
67
+ gap: 6px;
68
+ padding: 8px 10px;
69
+ border-bottom: 1px solid #F0EDE8;
70
+ cursor: pointer;
71
+ transition: background 0.1s;
72
+ }
73
+ .topics-row:hover
74
+ {
75
+ background: #F5F0EA;
76
+ }
77
+ .topics-row-info
78
+ {
79
+ flex: 1;
80
+ min-width: 0;
81
+ }
82
+ .topics-row-code
83
+ {
84
+ font-weight: 600;
85
+ font-size: 0.78rem;
86
+ color: #2E7D74;
87
+ white-space: nowrap;
88
+ overflow: hidden;
89
+ text-overflow: ellipsis;
90
+ }
91
+ .topics-row-title
92
+ {
93
+ font-size: 0.72rem;
94
+ color: #5E5549;
95
+ white-space: nowrap;
96
+ overflow: hidden;
97
+ text-overflow: ellipsis;
98
+ margin-top: 1px;
99
+ }
100
+ .topics-row-path
101
+ {
102
+ font-size: 0.68rem;
103
+ color: #8A7F72;
104
+ white-space: nowrap;
105
+ overflow: hidden;
106
+ text-overflow: ellipsis;
107
+ margin-top: 1px;
108
+ }
109
+ .topics-row-actions
110
+ {
111
+ flex-shrink: 0;
112
+ display: flex;
113
+ gap: 2px;
114
+ padding-top: 1px;
115
+ }
116
+ .topics-row-btn
117
+ {
118
+ background: transparent;
119
+ border: none;
120
+ cursor: pointer;
121
+ font-size: 0.72rem;
122
+ color: #8A7F72;
123
+ padding: 2px 4px;
124
+ border-radius: 3px;
125
+ line-height: 1;
126
+ }
127
+ .topics-row-btn:hover
128
+ {
129
+ color: #3D3229;
130
+ background: #EDE9E3;
131
+ }
132
+ .topics-row-btn-delete:hover
133
+ {
134
+ color: #D9534F;
135
+ background: #FDF0EF;
136
+ }
137
+ /* Inline edit form */
138
+ .topics-edit
139
+ {
140
+ padding: 8px 10px;
141
+ border-bottom: 1px solid #DDD6CA;
142
+ background: #FFF9F0;
143
+ }
144
+ .topics-edit-field
145
+ {
146
+ margin-bottom: 6px;
147
+ }
148
+ .topics-edit-label
149
+ {
150
+ display: block;
151
+ font-size: 0.68rem;
152
+ font-weight: 600;
153
+ color: #8A7F72;
154
+ margin-bottom: 2px;
155
+ }
156
+ .topics-edit-input
157
+ {
158
+ display: block;
159
+ width: 100%;
160
+ box-sizing: border-box;
161
+ padding: 4px 6px;
162
+ font-size: 0.78rem;
163
+ border: 1px solid #DDD6CA;
164
+ border-radius: 3px;
165
+ background: #FFF;
166
+ color: #3D3229;
167
+ font-family: inherit;
168
+ }
169
+ .topics-edit-input:focus
170
+ {
171
+ outline: none;
172
+ border-color: #2E7D74;
173
+ }
174
+ .topics-edit-actions
175
+ {
176
+ display: flex;
177
+ gap: 6px;
178
+ margin-top: 8px;
179
+ }
180
+ .topics-edit-save
181
+ {
182
+ background: #2E7D74;
183
+ color: #FFF;
184
+ border: none;
185
+ border-radius: 3px;
186
+ padding: 4px 12px;
187
+ font-size: 0.72rem;
188
+ font-weight: 600;
189
+ cursor: pointer;
190
+ }
191
+ .topics-edit-save:hover
192
+ {
193
+ background: #3A9E92;
194
+ }
195
+ .topics-edit-cancel
196
+ {
197
+ background: transparent;
198
+ color: #5E5549;
199
+ border: 1px solid #DDD6CA;
200
+ border-radius: 3px;
201
+ padding: 4px 12px;
202
+ font-size: 0.72rem;
203
+ font-weight: 600;
204
+ cursor: pointer;
205
+ }
206
+ .topics-edit-cancel:hover
207
+ {
208
+ background: #F0EDE8;
209
+ }
210
+ /* Footer add button */
211
+ .topics-footer
212
+ {
213
+ flex-shrink: 0;
214
+ padding: 8px 10px;
215
+ border-top: 1px solid #EDE9E3;
216
+ background: #FAF8F4;
217
+ }
218
+ .topics-add-btn
219
+ {
220
+ display: block;
221
+ width: 100%;
222
+ padding: 6px 0;
223
+ background: #2E7D74;
224
+ color: #FFF;
225
+ border: none;
226
+ border-radius: 4px;
227
+ font-size: 0.78rem;
228
+ font-weight: 600;
229
+ cursor: pointer;
230
+ text-align: center;
231
+ }
232
+ .topics-add-btn:hover
233
+ {
234
+ background: #3A9E92;
235
+ }
236
+ /* Empty state */
237
+ .topics-empty
238
+ {
239
+ display: flex;
240
+ flex-direction: column;
241
+ align-items: center;
242
+ justify-content: center;
243
+ gap: 12px;
244
+ padding: 32px 16px;
245
+ text-align: center;
246
+ color: #8A7F72;
247
+ font-size: 0.82rem;
248
+ }
249
+ .topics-empty-icon
250
+ {
251
+ font-size: 2rem;
252
+ color: #C4BDB3;
253
+ }
254
+ .topics-empty-btn
255
+ {
256
+ display: inline-block;
257
+ padding: 6px 14px;
258
+ background: #2E7D74;
259
+ color: #FFF;
260
+ border: none;
261
+ border-radius: 4px;
262
+ font-size: 0.78rem;
263
+ font-weight: 600;
264
+ cursor: pointer;
265
+ }
266
+ .topics-empty-btn:hover
267
+ {
268
+ background: #3A9E92;
269
+ }
270
+ .topics-empty-btn-secondary
271
+ {
272
+ background: transparent;
273
+ color: #5E5549;
274
+ border: 1px solid #DDD6CA;
275
+ }
276
+ .topics-empty-btn-secondary:hover
277
+ {
278
+ background: #F0EDE8;
279
+ border-color: #8A7F72;
280
+ }
281
+ `,
282
+
283
+ Templates:
284
+ [
285
+ {
286
+ Hash: "Topics-Container-Template",
287
+ Template: /*html*/`
288
+ <div class="topics-container" id="ContentEditor-Topics-Container">
289
+ <div class="topics-header">
290
+ <span class="topics-header-title" id="ContentEditor-Topics-HeaderTitle">Topics</span>
291
+ <button class="topics-header-btn" title="Close topics file"
292
+ onclick="pict.views['ContentEditor-Topics'].closeTopicsFile()">&times;</button>
293
+ </div>
294
+ <div class="topics-list" id="ContentEditor-Topics-List"></div>
295
+ <div class="topics-footer" id="ContentEditor-Topics-Footer">
296
+ <button class="topics-add-btn"
297
+ onclick="pict.views['ContentEditor-Topics'].addTopic()">+ Add Topic</button>
298
+ </div>
299
+ </div>
300
+ `
301
+ }
302
+ ],
303
+
304
+ Renderables:
305
+ [
306
+ {
307
+ RenderableHash: "Topics-Wrap",
308
+ TemplateHash: "Topics-Container-Template",
309
+ DestinationAddress: "#ContentEditor-SidebarTopics-Container"
310
+ }
311
+ ]
312
+ };
313
+
314
+ /**
315
+ * Content Editor Topics View
316
+ *
317
+ * Manages .pict_documentation_topics.json files — JSON manifests that
318
+ * map topic codes to help file paths and titles for built-in
319
+ * application documentation.
320
+ *
321
+ * Supports full CRUD on topic entries with inline editing.
322
+ */
323
+ class ContentEditorTopicsView extends libPictView
324
+ {
325
+ constructor(pFable, pOptions, pServiceHash)
326
+ {
327
+ super(pFable, pOptions, pServiceHash);
328
+
329
+ // The parsed topics object (keyed by TopicCode)
330
+ this._topics = {};
331
+
332
+ // The file path of the currently loaded topics file
333
+ this._topicsFilePath = '';
334
+
335
+ // Whether the view has been rendered
336
+ this._hasRendered = false;
337
+
338
+ // The TopicCode currently being edited (null if none)
339
+ this._editingTopicCode = null;
340
+ }
341
+
342
+ onAfterRender()
343
+ {
344
+ this._hasRendered = true;
345
+ this.pict.CSSMap.injectCSS();
346
+
347
+ // Check if we should show the empty state or the topic list
348
+ if (!this._topicsFilePath)
349
+ {
350
+ this._showEmptyState();
351
+ }
352
+ else
353
+ {
354
+ this._updateHeaderTitle();
355
+ this.renderTopicList();
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Load a topics JSON file from the server.
361
+ *
362
+ * @param {string} pPath - Relative path to the topics JSON file
363
+ * @param {Function} [fCallback] - Optional callback (error)
364
+ */
365
+ loadTopicsFile(pPath, fCallback)
366
+ {
367
+ let tmpCallback = (typeof (fCallback) === 'function') ? fCallback : () => {};
368
+ let tmpSelf = this;
369
+
370
+ if (!pPath)
371
+ {
372
+ return tmpCallback('No path specified');
373
+ }
374
+
375
+ let tmpProvider = this.pict.providers['ContentEditor-Provider'];
376
+ if (!tmpProvider)
377
+ {
378
+ return tmpCallback('Provider not available');
379
+ }
380
+
381
+ tmpProvider.loadFile(pPath, (pError, pContent) =>
382
+ {
383
+ if (pError)
384
+ {
385
+ // If the default file doesn't exist, that's OK — show empty state
386
+ tmpSelf._topics = {};
387
+ tmpSelf._topicsFilePath = '';
388
+ if (tmpSelf._hasRendered)
389
+ {
390
+ tmpSelf._showEmptyState();
391
+ }
392
+ return tmpCallback(pError);
393
+ }
394
+
395
+ try
396
+ {
397
+ let tmpParsed = JSON.parse(pContent);
398
+ if (typeof (tmpParsed) === 'object' && tmpParsed !== null && !Array.isArray(tmpParsed))
399
+ {
400
+ tmpSelf._topics = tmpParsed;
401
+ }
402
+ else
403
+ {
404
+ tmpSelf._topics = {};
405
+ }
406
+ }
407
+ catch (pParseError)
408
+ {
409
+ tmpSelf._topics = {};
410
+ tmpSelf.log.warn('ContentEditor-Topics: Failed to parse topics JSON: ' + pParseError.message);
411
+ }
412
+
413
+ tmpSelf._topicsFilePath = pPath;
414
+
415
+ // Persist the path in settings
416
+ tmpSelf.pict.AppData.ContentEditor.TopicsFilePath = pPath;
417
+ tmpSelf.pict.PictApplication.saveSettings();
418
+
419
+ if (tmpSelf._hasRendered)
420
+ {
421
+ tmpSelf._updateHeaderTitle();
422
+ tmpSelf.renderTopicList();
423
+ tmpSelf._showFooter(true);
424
+ }
425
+
426
+ return tmpCallback(null);
427
+ });
428
+ }
429
+
430
+ /**
431
+ * Save the current topics object back to the server.
432
+ *
433
+ * @param {Function} [fCallback] - Optional callback (error)
434
+ */
435
+ saveTopicsFile(fCallback)
436
+ {
437
+ let tmpCallback = (typeof (fCallback) === 'function') ? fCallback : () => {};
438
+
439
+ if (!this._topicsFilePath)
440
+ {
441
+ return tmpCallback('No topics file loaded');
442
+ }
443
+
444
+ let tmpProvider = this.pict.providers['ContentEditor-Provider'];
445
+ if (!tmpProvider)
446
+ {
447
+ return tmpCallback('Provider not available');
448
+ }
449
+
450
+ let tmpContent = JSON.stringify(this._topics, null, '\t');
451
+ tmpProvider.saveFile(this._topicsFilePath, tmpContent, tmpCallback);
452
+ }
453
+
454
+ /**
455
+ * Close the currently loaded topics file.
456
+ */
457
+ closeTopicsFile()
458
+ {
459
+ this._topics = {};
460
+ this._topicsFilePath = '';
461
+ this._editingTopicCode = null;
462
+
463
+ this.pict.AppData.ContentEditor.TopicsFilePath = '';
464
+ this.pict.PictApplication.saveSettings();
465
+
466
+ if (this._hasRendered)
467
+ {
468
+ this._showEmptyState();
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Add a new topic entry.
474
+ *
475
+ * @param {Object} [pTopicData] - Optional pre-filled topic data
476
+ */
477
+ addTopic(pTopicData)
478
+ {
479
+ if (!this._topicsFilePath)
480
+ {
481
+ // If no file loaded, create the default one
482
+ this._createDefaultTopicsFile(() =>
483
+ {
484
+ this.addTopic(pTopicData);
485
+ });
486
+ return;
487
+ }
488
+
489
+ let tmpData = pTopicData || {};
490
+ let tmpCode = tmpData.TopicCode || this._generateUniqueCode('New-Topic');
491
+
492
+ let tmpTopic =
493
+ {
494
+ TopicCode: tmpCode,
495
+ TopicHelpFilePath: tmpData.TopicHelpFilePath || '',
496
+ TopicTitle: tmpData.TopicTitle || ''
497
+ };
498
+
499
+ if (typeof (tmpData.RelevantMarkdownLine) === 'number')
500
+ {
501
+ tmpTopic.RelevantMarkdownLine = tmpData.RelevantMarkdownLine;
502
+ }
503
+
504
+ this._topics[tmpCode] = tmpTopic;
505
+
506
+ let tmpSelf = this;
507
+ this.saveTopicsFile(() =>
508
+ {
509
+ tmpSelf.renderTopicList();
510
+ tmpSelf.startEditTopic(tmpCode);
511
+ });
512
+ }
513
+
514
+ /**
515
+ * Remove a topic entry after confirmation.
516
+ *
517
+ * @param {string} pTopicCode - The TopicCode to remove
518
+ */
519
+ removeTopic(pTopicCode)
520
+ {
521
+ if (!pTopicCode || !this._topics[pTopicCode])
522
+ {
523
+ return;
524
+ }
525
+
526
+ if (!confirm('Remove topic "' + pTopicCode + '"?'))
527
+ {
528
+ return;
529
+ }
530
+
531
+ delete this._topics[pTopicCode];
532
+ this._editingTopicCode = null;
533
+
534
+ let tmpSelf = this;
535
+ this.saveTopicsFile(() =>
536
+ {
537
+ tmpSelf.renderTopicList();
538
+ });
539
+ }
540
+
541
+ /**
542
+ * Switch a topic row into inline edit mode.
543
+ *
544
+ * @param {string} pTopicCode - The TopicCode to edit
545
+ */
546
+ startEditTopic(pTopicCode)
547
+ {
548
+ if (!pTopicCode || !this._topics[pTopicCode])
549
+ {
550
+ return;
551
+ }
552
+
553
+ this._editingTopicCode = pTopicCode;
554
+ this.renderTopicList();
555
+
556
+ // Focus the first input field
557
+ let tmpInput = document.getElementById('topics-edit-code');
558
+ if (tmpInput)
559
+ {
560
+ tmpInput.focus();
561
+ tmpInput.select();
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Save the inline edit form values back into the topics object.
567
+ *
568
+ * @param {string} pOriginalCode - The original TopicCode being edited
569
+ */
570
+ saveEditTopic(pOriginalCode)
571
+ {
572
+ if (!pOriginalCode || !this._topics[pOriginalCode])
573
+ {
574
+ return;
575
+ }
576
+
577
+ let tmpCodeInput = document.getElementById('topics-edit-code');
578
+ let tmpTitleInput = document.getElementById('topics-edit-title');
579
+ let tmpPathInput = document.getElementById('topics-edit-path');
580
+ let tmpLineInput = document.getElementById('topics-edit-line');
581
+
582
+ if (!tmpCodeInput)
583
+ {
584
+ return;
585
+ }
586
+
587
+ let tmpNewCode = tmpCodeInput.value.trim();
588
+ let tmpNewTitle = tmpTitleInput ? tmpTitleInput.value.trim() : '';
589
+ let tmpNewPath = tmpPathInput ? tmpPathInput.value.trim() : '';
590
+ let tmpNewLine = tmpLineInput ? parseInt(tmpLineInput.value, 10) : NaN;
591
+
592
+ // Validate: TopicCode must not be empty
593
+ if (!tmpNewCode)
594
+ {
595
+ tmpCodeInput.style.borderColor = '#D9534F';
596
+ return;
597
+ }
598
+
599
+ // Validate: if code changed, it must be unique
600
+ if (tmpNewCode !== pOriginalCode && this._topics[tmpNewCode])
601
+ {
602
+ tmpCodeInput.style.borderColor = '#D9534F';
603
+ alert('A topic with code "' + tmpNewCode + '" already exists.');
604
+ return;
605
+ }
606
+
607
+ // Remove the old entry if the code changed
608
+ if (tmpNewCode !== pOriginalCode)
609
+ {
610
+ delete this._topics[pOriginalCode];
611
+ }
612
+
613
+ let tmpTopic =
614
+ {
615
+ TopicCode: tmpNewCode,
616
+ TopicHelpFilePath: tmpNewPath,
617
+ TopicTitle: tmpNewTitle
618
+ };
619
+
620
+ if (!isNaN(tmpNewLine) && tmpNewLine > 0)
621
+ {
622
+ tmpTopic.RelevantMarkdownLine = tmpNewLine;
623
+ }
624
+
625
+ this._topics[tmpNewCode] = tmpTopic;
626
+ this._editingTopicCode = null;
627
+
628
+ let tmpSelf = this;
629
+ this.saveTopicsFile(() =>
630
+ {
631
+ tmpSelf.renderTopicList();
632
+ });
633
+ }
634
+
635
+ /**
636
+ * Cancel inline editing and re-render the list.
637
+ */
638
+ cancelEditTopic()
639
+ {
640
+ this._editingTopicCode = null;
641
+ this.renderTopicList();
642
+ }
643
+
644
+ /**
645
+ * Navigate to a topic's file in the editor, scrolling to
646
+ * RelevantMarkdownLine if present.
647
+ *
648
+ * @param {string} pTopicCode - The TopicCode to navigate to
649
+ */
650
+ navigateToTopic(pTopicCode)
651
+ {
652
+ if (!pTopicCode || !this._topics[pTopicCode])
653
+ {
654
+ return;
655
+ }
656
+
657
+ let tmpTopic = this._topics[pTopicCode];
658
+ let tmpFilePath = tmpTopic.TopicHelpFilePath;
659
+
660
+ if (!tmpFilePath)
661
+ {
662
+ return;
663
+ }
664
+
665
+ this.pict.PictApplication.navigateToFile(tmpFilePath);
666
+
667
+ // If there's a RelevantMarkdownLine, scroll to it after a brief delay
668
+ // to allow the editor to render
669
+ if (typeof (tmpTopic.RelevantMarkdownLine) === 'number' && tmpTopic.RelevantMarkdownLine > 0)
670
+ {
671
+ let tmpLine = tmpTopic.RelevantMarkdownLine;
672
+ setTimeout(() =>
673
+ {
674
+ let tmpEditorView = this.pict.views['ContentEditor-MarkdownEditor'];
675
+ if (tmpEditorView && tmpEditorView._segmentEditors)
676
+ {
677
+ // Find the segment and line to scroll to
678
+ let tmpRunningLines = 0;
679
+ for (let tmpKey in tmpEditorView._segmentEditors)
680
+ {
681
+ let tmpEditor = tmpEditorView._segmentEditors[tmpKey];
682
+ if (tmpEditor && tmpEditor.state && tmpEditor.state.doc)
683
+ {
684
+ let tmpSegmentLines = tmpEditor.state.doc.lines;
685
+ if (tmpRunningLines + tmpSegmentLines >= tmpLine)
686
+ {
687
+ // This segment contains the target line
688
+ let tmpLocalLine = tmpLine - tmpRunningLines;
689
+ if (tmpLocalLine < 1) tmpLocalLine = 1;
690
+ if (tmpLocalLine > tmpSegmentLines) tmpLocalLine = tmpSegmentLines;
691
+ let tmpLineInfo = tmpEditor.state.doc.line(tmpLocalLine);
692
+ tmpEditor.dispatch({
693
+ selection: { anchor: tmpLineInfo.from },
694
+ scrollIntoView: true
695
+ });
696
+ tmpEditor.focus();
697
+ break;
698
+ }
699
+ tmpRunningLines += tmpSegmentLines;
700
+ }
701
+ }
702
+ }
703
+ }, 500);
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Rebuild the topic list innerHTML from this._topics.
709
+ */
710
+ renderTopicList()
711
+ {
712
+ let tmpListEl = document.getElementById('ContentEditor-Topics-List');
713
+ if (!tmpListEl)
714
+ {
715
+ return;
716
+ }
717
+
718
+ let tmpKeys = Object.keys(this._topics);
719
+
720
+ if (tmpKeys.length === 0)
721
+ {
722
+ tmpListEl.innerHTML = '<div style="padding:16px;text-align:center;color:#8A7F72;font-size:0.78rem;">No topics yet. Click "+ Add Topic" to create one.</div>';
723
+ return;
724
+ }
725
+
726
+ let tmpHTML = '';
727
+
728
+ for (let i = 0; i < tmpKeys.length; i++)
729
+ {
730
+ let tmpCode = tmpKeys[i];
731
+ let tmpTopic = this._topics[tmpCode];
732
+
733
+ if (this._editingTopicCode === tmpCode)
734
+ {
735
+ // Render inline edit form
736
+ tmpHTML += this._buildEditFormHTML(tmpTopic);
737
+ }
738
+ else
739
+ {
740
+ // Render topic row
741
+ tmpHTML += this._buildTopicRowHTML(tmpTopic);
742
+ }
743
+ }
744
+
745
+ tmpListEl.innerHTML = tmpHTML;
746
+ }
747
+
748
+ /**
749
+ * Build the HTML for a topic row.
750
+ *
751
+ * @param {Object} pTopic - The topic object
752
+ * @returns {string} HTML string
753
+ */
754
+ _buildTopicRowHTML(pTopic)
755
+ {
756
+ let tmpCode = this._escapeHTML(pTopic.TopicCode || '');
757
+ let tmpTitle = this._escapeHTML(pTopic.TopicTitle || '');
758
+ let tmpPath = this._escapeHTML(pTopic.TopicHelpFilePath || '');
759
+ let tmpLine = (typeof (pTopic.RelevantMarkdownLine) === 'number') ? ' :' + pTopic.RelevantMarkdownLine : '';
760
+ let tmpCodeEscaped = this._escapeAttr(pTopic.TopicCode || '');
761
+
762
+ let tmpHTML = '<div class="topics-row" ondblclick="pict.views[\'ContentEditor-Topics\'].startEditTopic(\'' + tmpCodeEscaped + '\')">';
763
+ tmpHTML += '<div class="topics-row-info">';
764
+ tmpHTML += '<div class="topics-row-code">' + tmpCode + '</div>';
765
+ if (tmpTitle)
766
+ {
767
+ tmpHTML += '<div class="topics-row-title">' + tmpTitle + '</div>';
768
+ }
769
+ if (tmpPath)
770
+ {
771
+ tmpHTML += '<div class="topics-row-path">' + tmpPath + tmpLine + '</div>';
772
+ }
773
+ tmpHTML += '</div>';
774
+ tmpHTML += '<div class="topics-row-actions">';
775
+ tmpHTML += '<button class="topics-row-btn" title="Edit" onclick="event.stopPropagation();pict.views[\'ContentEditor-Topics\'].startEditTopic(\'' + tmpCodeEscaped + '\')">\u270E</button>';
776
+ tmpHTML += '<button class="topics-row-btn topics-row-btn-delete" title="Delete" onclick="event.stopPropagation();pict.views[\'ContentEditor-Topics\'].removeTopic(\'' + tmpCodeEscaped + '\')">\u2716</button>';
777
+ if (tmpPath)
778
+ {
779
+ tmpHTML += '<button class="topics-row-btn" title="Go to file" onclick="event.stopPropagation();pict.views[\'ContentEditor-Topics\'].navigateToTopic(\'' + tmpCodeEscaped + '\')">\u2192</button>';
780
+ }
781
+ tmpHTML += '</div>';
782
+ tmpHTML += '</div>';
783
+
784
+ return tmpHTML;
785
+ }
786
+
787
+ /**
788
+ * Build the HTML for an inline edit form.
789
+ *
790
+ * @param {Object} pTopic - The topic object being edited
791
+ * @returns {string} HTML string
792
+ */
793
+ _buildEditFormHTML(pTopic)
794
+ {
795
+ let tmpCode = this._escapeAttr(pTopic.TopicCode || '');
796
+ let tmpTitle = this._escapeAttr(pTopic.TopicTitle || '');
797
+ let tmpPath = this._escapeAttr(pTopic.TopicHelpFilePath || '');
798
+ let tmpLine = (typeof (pTopic.RelevantMarkdownLine) === 'number') ? pTopic.RelevantMarkdownLine : '';
799
+ let tmpOriginalCode = this._escapeAttr(pTopic.TopicCode || '');
800
+
801
+ let tmpHTML = '<div class="topics-edit">';
802
+ tmpHTML += '<div class="topics-edit-field">';
803
+ tmpHTML += '<label class="topics-edit-label">Topic Code</label>';
804
+ tmpHTML += '<input class="topics-edit-input" id="topics-edit-code" type="text" value="' + tmpCode + '" placeholder="My-Topic-Code">';
805
+ tmpHTML += '</div>';
806
+ tmpHTML += '<div class="topics-edit-field">';
807
+ tmpHTML += '<label class="topics-edit-label">Title</label>';
808
+ tmpHTML += '<input class="topics-edit-input" id="topics-edit-title" type="text" value="' + tmpTitle + '" placeholder="Topic title">';
809
+ tmpHTML += '</div>';
810
+ tmpHTML += '<div class="topics-edit-field">';
811
+ tmpHTML += '<label class="topics-edit-label">Help File Path</label>';
812
+ tmpHTML += '<input class="topics-edit-input" id="topics-edit-path" type="text" value="' + tmpPath + '" placeholder="path/to/file.md">';
813
+ tmpHTML += '</div>';
814
+ tmpHTML += '<div class="topics-edit-field">';
815
+ tmpHTML += '<label class="topics-edit-label">Line Number (optional)</label>';
816
+ tmpHTML += '<input class="topics-edit-input" id="topics-edit-line" type="number" value="' + tmpLine + '" placeholder="e.g. 23" min="1">';
817
+ tmpHTML += '</div>';
818
+ tmpHTML += '<div class="topics-edit-actions">';
819
+ tmpHTML += '<button class="topics-edit-save" onclick="pict.views[\'ContentEditor-Topics\'].saveEditTopic(\'' + tmpOriginalCode + '\')">Save</button>';
820
+ tmpHTML += '<button class="topics-edit-cancel" onclick="pict.views[\'ContentEditor-Topics\'].cancelEditTopic()">Cancel</button>';
821
+ tmpHTML += '</div>';
822
+ tmpHTML += '</div>';
823
+
824
+ return tmpHTML;
825
+ }
826
+
827
+ /**
828
+ * Show the empty state (no topics file loaded).
829
+ */
830
+ _showEmptyState()
831
+ {
832
+ let tmpContainer = document.getElementById('ContentEditor-Topics-Container');
833
+ if (!tmpContainer)
834
+ {
835
+ // If the container doesn't exist yet, just render the whole view
836
+ let tmpDestination = document.getElementById('ContentEditor-SidebarTopics-Container');
837
+ if (tmpDestination)
838
+ {
839
+ tmpDestination.innerHTML = this._buildEmptyStateHTML();
840
+ }
841
+ return;
842
+ }
843
+
844
+ tmpContainer.innerHTML = this._buildEmptyStateHTML();
845
+ }
846
+
847
+ /**
848
+ * Build the empty state HTML.
849
+ *
850
+ * @returns {string} HTML string
851
+ */
852
+ _buildEmptyStateHTML()
853
+ {
854
+ let tmpHTML = '<div class="topics-empty">';
855
+ tmpHTML += '<div class="topics-empty-icon">&#x1F4D1;</div>';
856
+ tmpHTML += '<div>No topics file loaded</div>';
857
+ tmpHTML += '<button class="topics-empty-btn" onclick="pict.views[\'ContentEditor-Topics\'].loadDefaultTopicsFile()">Load .pict_documentation_topics.json</button>';
858
+ tmpHTML += '<button class="topics-empty-btn topics-empty-btn-secondary" onclick="pict.views[\'ContentEditor-Topics\'].promptSelectTopicsFile()">Select file...</button>';
859
+ tmpHTML += '</div>';
860
+ return tmpHTML;
861
+ }
862
+
863
+ /**
864
+ * Attempt to load the default topics file (.pict_documentation_topics.json).
865
+ * If it doesn't exist, create it.
866
+ */
867
+ loadDefaultTopicsFile()
868
+ {
869
+ let tmpSelf = this;
870
+ let tmpDefaultPath = '.pict_documentation_topics.json';
871
+
872
+ this.loadTopicsFile(tmpDefaultPath, (pError) =>
873
+ {
874
+ if (pError)
875
+ {
876
+ // File doesn't exist — create it
877
+ tmpSelf._createDefaultTopicsFile();
878
+ }
879
+ });
880
+ }
881
+
882
+ /**
883
+ * Prompt the user for a custom topics file path.
884
+ */
885
+ promptSelectTopicsFile()
886
+ {
887
+ let tmpPath = prompt('Enter the path to a topics JSON file:', '.pict_documentation_topics.json');
888
+ if (tmpPath && tmpPath.trim())
889
+ {
890
+ let tmpSelf = this;
891
+ this.loadTopicsFile(tmpPath.trim(), (pError) =>
892
+ {
893
+ if (pError)
894
+ {
895
+ // File doesn't exist — offer to create it
896
+ if (confirm('File not found. Create "' + tmpPath.trim() + '"?'))
897
+ {
898
+ tmpSelf._topicsFilePath = tmpPath.trim();
899
+ tmpSelf._topics = {};
900
+ tmpSelf.pict.AppData.ContentEditor.TopicsFilePath = tmpPath.trim();
901
+ tmpSelf.pict.PictApplication.saveSettings();
902
+ tmpSelf.saveTopicsFile(() =>
903
+ {
904
+ if (tmpSelf._hasRendered)
905
+ {
906
+ tmpSelf.render();
907
+ }
908
+ });
909
+ }
910
+ }
911
+ });
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Create the default topics file with empty contents.
917
+ *
918
+ * @param {Function} [fCallback] - Optional callback when done
919
+ */
920
+ _createDefaultTopicsFile(fCallback)
921
+ {
922
+ let tmpCallback = (typeof (fCallback) === 'function') ? fCallback : () => {};
923
+ let tmpSelf = this;
924
+ let tmpDefaultPath = '.pict_documentation_topics.json';
925
+
926
+ this._topicsFilePath = tmpDefaultPath;
927
+ this._topics = {};
928
+
929
+ this.pict.AppData.ContentEditor.TopicsFilePath = tmpDefaultPath;
930
+ this.pict.PictApplication.saveSettings();
931
+
932
+ this.saveTopicsFile(() =>
933
+ {
934
+ if (tmpSelf._hasRendered)
935
+ {
936
+ tmpSelf.render();
937
+ }
938
+ tmpCallback();
939
+ });
940
+ }
941
+
942
+ /**
943
+ * Update the header title bar with the current file name.
944
+ */
945
+ _updateHeaderTitle()
946
+ {
947
+ let tmpTitle = document.getElementById('ContentEditor-Topics-HeaderTitle');
948
+ if (tmpTitle)
949
+ {
950
+ let tmpFileName = this._topicsFilePath.replace(/^.*\//, '');
951
+ tmpTitle.textContent = tmpFileName || 'Topics';
952
+ tmpTitle.title = this._topicsFilePath;
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Show or hide the footer (add button area).
958
+ *
959
+ * @param {boolean} pShow
960
+ */
961
+ _showFooter(pShow)
962
+ {
963
+ let tmpFooter = document.getElementById('ContentEditor-Topics-Footer');
964
+ if (tmpFooter)
965
+ {
966
+ tmpFooter.style.display = pShow ? '' : 'none';
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Generate a unique topic code by appending a suffix if needed.
972
+ *
973
+ * @param {string} pBase - The base code
974
+ * @returns {string} A unique code
975
+ */
976
+ _generateUniqueCode(pBase)
977
+ {
978
+ if (!this._topics[pBase])
979
+ {
980
+ return pBase;
981
+ }
982
+
983
+ let tmpCounter = 2;
984
+ while (this._topics[pBase + '-' + tmpCounter])
985
+ {
986
+ tmpCounter++;
987
+ }
988
+ return pBase + '-' + tmpCounter;
989
+ }
990
+
991
+ /**
992
+ * HTML-escape a string for safe insertion.
993
+ *
994
+ * @param {string} pStr
995
+ * @returns {string}
996
+ */
997
+ _escapeHTML(pStr)
998
+ {
999
+ return String(pStr)
1000
+ .replace(/&/g, '&amp;')
1001
+ .replace(/</g, '&lt;')
1002
+ .replace(/>/g, '&gt;')
1003
+ .replace(/"/g, '&quot;');
1004
+ }
1005
+
1006
+ /**
1007
+ * Escape a string for use in an HTML attribute value.
1008
+ *
1009
+ * @param {string} pStr
1010
+ * @returns {string}
1011
+ */
1012
+ _escapeAttr(pStr)
1013
+ {
1014
+ return String(pStr)
1015
+ .replace(/&/g, '&amp;')
1016
+ .replace(/'/g, '&#39;')
1017
+ .replace(/"/g, '&quot;')
1018
+ .replace(/</g, '&lt;')
1019
+ .replace(/>/g, '&gt;');
1020
+ }
1021
+ }
1022
+
1023
+ module.exports = ContentEditorTopicsView;
1024
+
1025
+ module.exports.default_configuration = _ViewConfiguration;