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,1361 @@
1
+ const libPictApplication = require('pict-application');
2
+
3
+ // File browser
4
+ const libPictSectionFileBrowser = require('pict-section-filebrowser');
5
+
6
+ // Provider
7
+ const libContentEditorProvider = require('./providers/Pict-Provider-ContentEditor.js');
8
+
9
+ // Views
10
+ const libViewLayout = require('./views/PictView-Editor-Layout.js');
11
+ const libViewTopBar = require('./views/PictView-Editor-TopBar.js');
12
+ const libViewMarkdownEditor = require('./views/PictView-Editor-MarkdownEditor.js');
13
+ const libViewCodeEditor = require('./views/PictView-Editor-CodeEditor.js');
14
+ const libViewSettingsPanel = require('./views/PictView-Editor-SettingsPanel.js');
15
+ const libViewMarkdownReference = require('./views/PictView-Editor-MarkdownReference.js');
16
+ const libViewTopics = require('./views/PictView-Editor-Topics.js');
17
+
18
+ /**
19
+ * Content Editor Application
20
+ *
21
+ * A Pict application for editing markdown files served by the
22
+ * retold-content-system Orator server. Uses pict-section-markdowneditor
23
+ * for the editing experience and pict-section-filebrowser for file browsing.
24
+ */
25
+ class ContentEditorApplication extends libPictApplication
26
+ {
27
+ constructor(pFable, pOptions, pServiceHash)
28
+ {
29
+ super(pFable, pOptions, pServiceHash);
30
+
31
+ // Register the content editor provider
32
+ this.pict.addProvider('ContentEditor-Provider', libContentEditorProvider.default_configuration, libContentEditorProvider);
33
+
34
+ // Register views
35
+ this.pict.addView('ContentEditor-Layout', libViewLayout.default_configuration, libViewLayout);
36
+ this.pict.addView('ContentEditor-TopBar', libViewTopBar.default_configuration, libViewTopBar);
37
+ this.pict.addView('ContentEditor-MarkdownEditor', libViewMarkdownEditor.default_configuration, libViewMarkdownEditor);
38
+ this.pict.addView('ContentEditor-CodeEditor', libViewCodeEditor.default_configuration, libViewCodeEditor);
39
+ this.pict.addView('ContentEditor-SettingsPanel', libViewSettingsPanel.default_configuration, libViewSettingsPanel);
40
+ this.pict.addView('ContentEditor-MarkdownReference', libViewMarkdownReference.default_configuration, libViewMarkdownReference);
41
+ this.pict.addView('ContentEditor-Topics', libViewTopics.default_configuration, libViewTopics);
42
+
43
+ // Register the file browser -- override destination and layout for sidebar use
44
+ let tmpFileBrowserConfig = JSON.parse(JSON.stringify(libPictSectionFileBrowser.default_configuration));
45
+ tmpFileBrowserConfig.DefaultDestinationAddress = '#ContentEditor-Sidebar-Container';
46
+ tmpFileBrowserConfig.DefaultState.Layout = 'list-only';
47
+ this.pict.addView('Pict-FileBrowser', tmpFileBrowserConfig, libPictSectionFileBrowser);
48
+
49
+ // Register the list detail sub-view for the file list pane
50
+ this.pict.addView('Pict-FileBrowser-ListDetail',
51
+ libPictSectionFileBrowser.PictViewListDetail.default_configuration,
52
+ libPictSectionFileBrowser.PictViewListDetail);
53
+ }
54
+
55
+ onAfterInitializeAsync(fCallback)
56
+ {
57
+ // Expose the pict instance as window.pict for inline onclick handlers
58
+ // (pict-section-filebrowser templates reference pict.views[...])
59
+ if (typeof (window) !== 'undefined')
60
+ {
61
+ window.pict = this.pict;
62
+ }
63
+
64
+ // Initialize application state
65
+ this.pict.AppData.ContentEditor =
66
+ {
67
+ CurrentFile: '',
68
+ ActiveEditor: 'markdown', // 'markdown' or 'code'
69
+ IsDirty: false,
70
+ IsSaving: false,
71
+ IsLoading: false,
72
+ Files: [],
73
+ Document:
74
+ {
75
+ Segments: [{ Content: '' }]
76
+ },
77
+ CodeContent: '',
78
+ SaveStatus: '',
79
+ SaveStatusClass: '',
80
+
81
+ // Settings
82
+ AutoSegmentMarkdown: false,
83
+ AutoSegmentDepth: 1,
84
+ AutoContentPreview: false,
85
+ MarkdownEditingControls: true,
86
+ MarkdownWordWrap: true,
87
+ CodeWordWrap: false,
88
+ SidebarCollapsed: false,
89
+ SidebarWidth: 250,
90
+ AutoPreviewImages: true,
91
+ AutoPreviewVideo: false,
92
+ AutoPreviewAudio: false,
93
+ ShowHiddenFiles: false,
94
+ TopicsFilePath: '.pict_documentation_topics.json'
95
+ };
96
+
97
+ // Restore persisted settings from localStorage
98
+ this._loadSettings();
99
+
100
+ // Render the layout shell
101
+ this.pict.views['ContentEditor-Layout'].render();
102
+
103
+ // Wire up file selection from the file browser
104
+ let tmpSelf = this;
105
+ let tmpListProvider = this.pict.providers['Pict-FileBrowser-List'];
106
+ if (tmpListProvider)
107
+ {
108
+ let tmpOriginalSelectFile = tmpListProvider.selectFile.bind(tmpListProvider);
109
+ tmpListProvider.selectFile = function (pFileEntry)
110
+ {
111
+ tmpOriginalSelectFile(pFileEntry);
112
+ if (pFileEntry && pFileEntry.Type === 'file')
113
+ {
114
+ tmpSelf.navigateToFile(pFileEntry.Path);
115
+ }
116
+ };
117
+ }
118
+
119
+ // Wire up folder navigation to fetch subfolder contents from the server
120
+ let tmpBrowseProvider = this.pict.providers['Pict-FileBrowser-Browse'];
121
+ if (tmpBrowseProvider)
122
+ {
123
+ let tmpOriginalNavigate = tmpBrowseProvider.navigateToFolder.bind(tmpBrowseProvider);
124
+ tmpBrowseProvider.navigateToFolder = function (pPath)
125
+ {
126
+ // Update the CurrentLocation state (breadcrumb, etc.)
127
+ tmpOriginalNavigate(pPath);
128
+ // Fetch the new folder's contents from the server
129
+ tmpSelf.loadFileList(pPath);
130
+ };
131
+ }
132
+
133
+ // Sync the hidden files setting to the server before loading files
134
+ this.syncHiddenFilesSetting(() =>
135
+ {
136
+ // Load the file list into the file browser
137
+ tmpSelf.loadFileList(null, () =>
138
+ {
139
+ // Check if there is a hash route to load
140
+ tmpSelf.resolveHash();
141
+ });
142
+ });
143
+
144
+ // Silently attempt to load the topics file
145
+ let tmpTopicsPath = this.pict.AppData.ContentEditor.TopicsFilePath;
146
+ if (tmpTopicsPath)
147
+ {
148
+ let tmpTopicsView = this.pict.views['ContentEditor-Topics'];
149
+ if (tmpTopicsView)
150
+ {
151
+ tmpTopicsView.loadTopicsFile(tmpTopicsPath, () =>
152
+ {
153
+ // Silently ignore errors — the file may not exist yet
154
+ });
155
+ }
156
+ }
157
+
158
+ return super.onAfterInitializeAsync(fCallback);
159
+ }
160
+
161
+ /**
162
+ * Push the ShowHiddenFiles setting to the server so the file
163
+ * browser API includes or excludes dotfiles accordingly.
164
+ *
165
+ * @param {Function} [fCallback] - Optional callback when done
166
+ */
167
+ syncHiddenFilesSetting(fCallback)
168
+ {
169
+ let tmpCallback = (typeof (fCallback) === 'function') ? fCallback : () => {};
170
+ let tmpShow = this.pict.AppData.ContentEditor.ShowHiddenFiles;
171
+
172
+ fetch('/api/filebrowser/settings',
173
+ {
174
+ method: 'PUT',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify({ IncludeHiddenFiles: !!tmpShow })
177
+ })
178
+ .then(() => tmpCallback())
179
+ .catch(() => tmpCallback());
180
+ }
181
+
182
+ /**
183
+ * Load the file list from the server into the file browser.
184
+ *
185
+ * @param {string} [pPath] - Optional relative path to list (defaults to root)
186
+ * @param {Function} [fCallback] - Optional callback when done
187
+ */
188
+ loadFileList(pPath, fCallback)
189
+ {
190
+ let tmpCallback = (typeof (fCallback) === 'function') ? fCallback :
191
+ (typeof (pPath) === 'function') ? pPath : () => {};
192
+ let tmpSelf = this;
193
+
194
+ // Use the provided path, or fall back to the current browse location
195
+ let tmpPath = (typeof (pPath) === 'string') ? pPath : null;
196
+ if (tmpPath === null && this.pict.AppData.PictFileBrowser && this.pict.AppData.PictFileBrowser.CurrentLocation)
197
+ {
198
+ tmpPath = this.pict.AppData.PictFileBrowser.CurrentLocation;
199
+ }
200
+
201
+ let tmpURL = '/api/filebrowser/list';
202
+ if (tmpPath && tmpPath.length > 0)
203
+ {
204
+ tmpURL += '?path=' + encodeURIComponent(tmpPath);
205
+ }
206
+
207
+ fetch(tmpURL)
208
+ .then((pResponse) => pResponse.json())
209
+ .then((pFileList) =>
210
+ {
211
+ // FileBrowserService returns an array directly
212
+ tmpSelf.pict.AppData.PictFileBrowser =
213
+ tmpSelf.pict.AppData.PictFileBrowser || {};
214
+ tmpSelf.pict.AppData.PictFileBrowser.FileList = pFileList || [];
215
+
216
+ // Render the file browser container (creates pane structure)
217
+ let tmpFileBrowserView = tmpSelf.pict.views['Pict-FileBrowser'];
218
+ if (tmpFileBrowserView)
219
+ {
220
+ tmpFileBrowserView.render();
221
+ }
222
+
223
+ // Render the list detail sub-view (populates the list pane with file rows)
224
+ let tmpListDetailView = tmpSelf.pict.views['Pict-FileBrowser-ListDetail'];
225
+ if (tmpListDetailView)
226
+ {
227
+ tmpListDetailView.render();
228
+ }
229
+
230
+ return tmpCallback();
231
+ })
232
+ .catch((pError) =>
233
+ {
234
+ tmpSelf.log.error(`Failed to load file list: ${pError.message}`);
235
+ return tmpCallback();
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Resolve the current hash route.
241
+ *
242
+ * Supports:
243
+ * #/edit/<filepath> — Load a file for editing
244
+ * (empty) — Show welcome state
245
+ */
246
+ resolveHash()
247
+ {
248
+ let tmpHash = (window.location.hash || '').replace(/^#\/?/, '');
249
+
250
+ if (!tmpHash)
251
+ {
252
+ return;
253
+ }
254
+
255
+ let tmpParts = tmpHash.split('/');
256
+
257
+ if (tmpParts[0] === 'edit' && tmpParts.length >= 2)
258
+ {
259
+ let tmpFilePath = tmpParts.slice(1).join('/');
260
+ // Guard against duplicate navigation when navigateToFile()
261
+ // programmatically sets the hash and triggers hashchange.
262
+ if (this.pict.AppData.ContentEditor.CurrentFile === tmpFilePath)
263
+ {
264
+ return;
265
+ }
266
+ this.navigateToFile(tmpFilePath);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Determine whether a file should use the markdown editor, the code
272
+ * editor, or the binary file preview.
273
+ *
274
+ * @param {string} pFilePath - The file path
275
+ * @returns {string} 'markdown', 'code', or 'binary'
276
+ */
277
+ getEditorTypeForFile(pFilePath)
278
+ {
279
+ if (!pFilePath)
280
+ {
281
+ return 'markdown';
282
+ }
283
+ let tmpExt = pFilePath.replace(/^.*\./, '').toLowerCase();
284
+ if (tmpExt === 'md' || tmpExt === 'markdown')
285
+ {
286
+ return 'markdown';
287
+ }
288
+
289
+ // Known binary / non-editable file extensions
290
+ let tmpBinaryExtensions =
291
+ {
292
+ // Images
293
+ 'png': true, 'jpg': true, 'jpeg': true, 'gif': true, 'bmp': true,
294
+ 'webp': true, 'ico': true, 'svg': true, 'tiff': true, 'tif': true,
295
+ 'avif': true, 'heic': true, 'heif': true,
296
+ // Audio
297
+ 'mp3': true, 'wav': true, 'ogg': true, 'flac': true, 'aac': true,
298
+ 'm4a': true, 'wma': true,
299
+ // Video
300
+ 'mp4': true, 'avi': true, 'mkv': true, 'mov': true, 'wmv': true,
301
+ 'webm': true, 'flv': true, 'm4v': true,
302
+ // Documents / archives
303
+ 'pdf': true, 'doc': true, 'docx': true, 'xls': true, 'xlsx': true,
304
+ 'ppt': true, 'pptx': true, 'odt': true, 'ods': true, 'odp': true,
305
+ 'zip': true, 'tar': true, 'gz': true, 'bz2': true, 'xz': true,
306
+ '7z': true, 'rar': true,
307
+ // Fonts
308
+ 'ttf': true, 'otf': true, 'woff': true, 'woff2': true, 'eot': true,
309
+ // Executables / compiled
310
+ 'exe': true, 'dll': true, 'so': true, 'dylib': true, 'o': true,
311
+ 'class': true, 'pyc': true, 'wasm': true
312
+ };
313
+
314
+ if (tmpBinaryExtensions[tmpExt])
315
+ {
316
+ return 'binary';
317
+ }
318
+
319
+ return 'code';
320
+ }
321
+
322
+ /**
323
+ * Tear down whichever editor is currently active so the container
324
+ * is clean before showing a different view.
325
+ */
326
+ _cleanupEditors()
327
+ {
328
+ let tmpCodeEditorView = this.pict.views['ContentEditor-CodeEditor'];
329
+ if (tmpCodeEditorView)
330
+ {
331
+ if (tmpCodeEditorView.codeJar)
332
+ {
333
+ tmpCodeEditorView.destroy();
334
+ }
335
+ // Always reset so the next render() triggers onAfterInitialRender
336
+ tmpCodeEditorView.initialRenderComplete = false;
337
+ }
338
+
339
+ // Clear the container
340
+ let tmpEditorContainer = this.pict.ContentAssignment.getElement('#ContentEditor-Editor-Container');
341
+ if (tmpEditorContainer && tmpEditorContainer[0])
342
+ {
343
+ tmpEditorContainer[0].innerHTML = '';
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Format a byte count into a human-readable size string.
349
+ *
350
+ * @param {number} pBytes - The byte count
351
+ * @returns {string} Formatted string (e.g. "1.4 MB")
352
+ */
353
+ _formatFileSize(pBytes)
354
+ {
355
+ if (pBytes === 0) return '0 B';
356
+ let tmpUnits = ['B', 'KB', 'MB', 'GB', 'TB'];
357
+ let tmpIndex = Math.floor(Math.log(pBytes) / Math.log(1024));
358
+ if (tmpIndex >= tmpUnits.length) tmpIndex = tmpUnits.length - 1;
359
+ let tmpSize = pBytes / Math.pow(1024, tmpIndex);
360
+ return tmpSize.toFixed(tmpIndex === 0 ? 0 : 1) + ' ' + tmpUnits[tmpIndex];
361
+ }
362
+
363
+ /**
364
+ * Show the binary file preview card for a file that cannot be edited.
365
+ *
366
+ * @param {string} pFilePath - The relative path
367
+ */
368
+ /**
369
+ * Determine the media type of a binary file.
370
+ *
371
+ * @param {string} pExtension - Lowercase file extension
372
+ * @returns {string} 'image', 'video', 'audio', or 'other'
373
+ */
374
+ _getMediaType(pExtension)
375
+ {
376
+ let tmpImageExtensions = { 'png': true, 'jpg': true, 'jpeg': true, 'gif': true, 'webp': true, 'svg': true, 'bmp': true, 'ico': true, 'avif': true };
377
+ let tmpVideoExtensions = { 'mp4': true, 'webm': true, 'mov': true, 'mkv': true, 'avi': true, 'wmv': true, 'flv': true, 'm4v': true, 'ogv': true };
378
+ let tmpAudioExtensions = { 'mp3': true, 'wav': true, 'ogg': true, 'flac': true, 'aac': true, 'm4a': true, 'wma': true, 'oga': true };
379
+
380
+ if (tmpImageExtensions[pExtension]) return 'image';
381
+ if (tmpVideoExtensions[pExtension]) return 'video';
382
+ if (tmpAudioExtensions[pExtension]) return 'audio';
383
+ return 'other';
384
+ }
385
+
386
+ /**
387
+ * Build the inline media preview HTML for image, video, or audio.
388
+ *
389
+ * @param {string} pMediaType - 'image', 'video', or 'audio'
390
+ * @param {string} pContentURL - The URL to the media file
391
+ * @param {string} pFileName - The display file name
392
+ * @returns {string} HTML string
393
+ */
394
+ _buildMediaPreviewHTML(pMediaType, pContentURL, pFileName)
395
+ {
396
+ if (pMediaType === 'image')
397
+ {
398
+ return '<div class="binary-preview-image-wrap"><div class="binary-preview-image"><img src="' + pContentURL + '" alt="' + pFileName + '"></div></div>';
399
+ }
400
+ if (pMediaType === 'video')
401
+ {
402
+ return '<div class="binary-preview-media-wrap"><video class="binary-preview-video" controls preload="metadata"><source src="' + pContentURL + '">Your browser does not support the video tag.</video></div>';
403
+ }
404
+ if (pMediaType === 'audio')
405
+ {
406
+ return '<div class="binary-preview-media-wrap"><audio class="binary-preview-audio" controls preload="metadata"><source src="' + pContentURL + '">Your browser does not support the audio tag.</audio></div>';
407
+ }
408
+ return '';
409
+ }
410
+
411
+ /**
412
+ * Load a media preview into the placeholder container.
413
+ * Called when the user clicks the Preview button on a media card.
414
+ *
415
+ * @param {string} pMediaType - 'image', 'video', or 'audio'
416
+ * @param {string} pContentURL - The URL to the media file
417
+ * @param {string} pFileName - The display file name
418
+ */
419
+ loadMediaPreview(pMediaType, pContentURL, pFileName)
420
+ {
421
+ let tmpContainer = document.getElementById('ContentEditor-MediaPreviewPlaceholder');
422
+ if (!tmpContainer)
423
+ {
424
+ return;
425
+ }
426
+ tmpContainer.innerHTML = this._buildMediaPreviewHTML(pMediaType, pContentURL, pFileName);
427
+ }
428
+
429
+ _showBinaryPreview(pFilePath)
430
+ {
431
+ let tmpSelf = this;
432
+ let tmpFileName = pFilePath.replace(/^.*\//, '');
433
+ let tmpExtension = pFilePath.replace(/^.*\./, '').toLowerCase();
434
+ let tmpContentURL = '/content/' + encodeURIComponent(pFilePath);
435
+
436
+ let tmpMediaType = this._getMediaType(tmpExtension);
437
+ let tmpSettings = this.pict.AppData.ContentEditor;
438
+
439
+ // Determine whether to auto-preview based on settings
440
+ let tmpAutoPreview = false;
441
+ if (tmpMediaType === 'image') tmpAutoPreview = tmpSettings.AutoPreviewImages;
442
+ if (tmpMediaType === 'video') tmpAutoPreview = tmpSettings.AutoPreviewVideo;
443
+ if (tmpMediaType === 'audio') tmpAutoPreview = tmpSettings.AutoPreviewAudio;
444
+
445
+ // Fetch file info from the file browser API
446
+ fetch('/api/filebrowser/info?path=' + encodeURIComponent(pFilePath))
447
+ .then((pResponse) => pResponse.json())
448
+ .then((pInfo) =>
449
+ {
450
+ let tmpSize = (pInfo && typeof (pInfo.Size) === 'number') ? tmpSelf._formatFileSize(pInfo.Size) : 'Unknown';
451
+ let tmpModified = (pInfo && pInfo.Modified) ? new Date(pInfo.Modified).toLocaleString() : 'Unknown';
452
+
453
+ let tmpEditorContainer = tmpSelf.pict.ContentAssignment.getElement('#ContentEditor-Editor-Container');
454
+ if (!tmpEditorContainer || !tmpEditorContainer[0])
455
+ {
456
+ return;
457
+ }
458
+
459
+ let tmpPreviewHTML = '';
460
+
461
+ if (tmpMediaType !== 'other')
462
+ {
463
+ if (tmpAutoPreview)
464
+ {
465
+ tmpPreviewHTML += tmpSelf._buildMediaPreviewHTML(tmpMediaType, tmpContentURL, tmpFileName);
466
+ }
467
+ else
468
+ {
469
+ // Placeholder with a Preview button
470
+ tmpPreviewHTML += '<div id="ContentEditor-MediaPreviewPlaceholder">';
471
+ tmpPreviewHTML += '<button class="binary-preview-btn binary-preview-btn-preview"';
472
+ tmpPreviewHTML += ' onclick="pict.PictApplication.loadMediaPreview(';
473
+ tmpPreviewHTML += "'" + tmpMediaType + "','" + tmpContentURL + "','" + tmpFileName.replace(/'/g, "\\'") + "'";
474
+ tmpPreviewHTML += ')">Preview ' + tmpMediaType.charAt(0).toUpperCase() + tmpMediaType.slice(1) + '</button>';
475
+ tmpPreviewHTML += '</div>';
476
+ }
477
+ }
478
+
479
+ tmpPreviewHTML += '<div class="binary-preview-card">';
480
+ tmpPreviewHTML += '<div class="binary-preview-icon">' + tmpExtension.toUpperCase() + '</div>';
481
+ tmpPreviewHTML += '<div class="binary-preview-info">';
482
+ tmpPreviewHTML += '<div class="binary-preview-name">' + tmpFileName + '</div>';
483
+ tmpPreviewHTML += '<div class="binary-preview-meta">Size: ' + tmpSize + '</div>';
484
+ tmpPreviewHTML += '<div class="binary-preview-meta">Modified: ' + tmpModified + '</div>';
485
+ tmpPreviewHTML += '<div class="binary-preview-meta">Type: .' + tmpExtension + '</div>';
486
+ tmpPreviewHTML += '</div>';
487
+ tmpPreviewHTML += '<div class="binary-preview-actions">';
488
+ tmpPreviewHTML += '<a class="binary-preview-btn" href="' + tmpContentURL + '" download="' + tmpFileName + '">Download</a>';
489
+ tmpPreviewHTML += '<a class="binary-preview-btn binary-preview-btn-secondary" href="' + tmpContentURL + '" target="_blank">Open in New Tab</a>';
490
+ tmpPreviewHTML += '</div>';
491
+ tmpPreviewHTML += '</div>';
492
+
493
+ tmpEditorContainer[0].innerHTML = tmpPreviewHTML;
494
+ })
495
+ .catch(() =>
496
+ {
497
+ // Fallback if info fetch fails
498
+ let tmpEditorContainer = tmpSelf.pict.ContentAssignment.getElement('#ContentEditor-Editor-Container');
499
+ if (tmpEditorContainer && tmpEditorContainer[0])
500
+ {
501
+ tmpEditorContainer[0].innerHTML =
502
+ '<div class="binary-preview-card">' +
503
+ '<div class="binary-preview-icon">' + tmpExtension.toUpperCase() + '</div>' +
504
+ '<div class="binary-preview-info">' +
505
+ '<div class="binary-preview-name">' + tmpFileName + '</div>' +
506
+ '<div class="binary-preview-meta">Binary file — cannot be edited in the browser</div>' +
507
+ '</div>' +
508
+ '<div class="binary-preview-actions">' +
509
+ '<a class="binary-preview-btn" href="' + tmpContentURL + '" download="' + tmpFileName + '">Download</a>' +
510
+ '<a class="binary-preview-btn binary-preview-btn-secondary" href="' + tmpContentURL + '" target="_blank">Open in New Tab</a>' +
511
+ '</div></div>';
512
+ }
513
+ });
514
+ }
515
+
516
+ /**
517
+ * Segment markdown content based on the Auto Segment settings.
518
+ *
519
+ * When AutoSegmentMarkdown is enabled, splits the content into
520
+ * segments at the configured heading depth.
521
+ *
522
+ * Depth 1 splits every top-level block (paragraphs, code fences,
523
+ * headings, etc.) into its own segment. Depth 2+ splits at the
524
+ * corresponding heading level, keeping everything between two
525
+ * headings of that level (or higher) in the same segment.
526
+ *
527
+ * @param {string} pContent - Raw markdown text
528
+ * @returns {Array} Array of { Content: string } segment objects
529
+ */
530
+ segmentMarkdownContent(pContent)
531
+ {
532
+ let tmpSettings = this.pict.AppData.ContentEditor;
533
+
534
+ if (!tmpSettings.AutoSegmentMarkdown || !pContent)
535
+ {
536
+ return [{ Content: pContent || '' }];
537
+ }
538
+
539
+ let tmpDepth = parseInt(tmpSettings.AutoSegmentDepth, 10) || 1;
540
+
541
+ if (tmpDepth === 1)
542
+ {
543
+ // Depth 1: every block is its own segment.
544
+ // Split on blank lines, preserving fenced code blocks as
545
+ // single units.
546
+ let tmpLines = pContent.split('\n');
547
+ let tmpSegments = [];
548
+ let tmpCurrent = [];
549
+ let tmpInFence = false;
550
+
551
+ for (let i = 0; i < tmpLines.length; i++)
552
+ {
553
+ let tmpLine = tmpLines[i];
554
+
555
+ // Detect fenced code block boundaries
556
+ if (/^(`{3,}|~{3,})/.test(tmpLine.trim()))
557
+ {
558
+ tmpInFence = !tmpInFence;
559
+ tmpCurrent.push(tmpLine);
560
+ continue;
561
+ }
562
+
563
+ if (tmpInFence)
564
+ {
565
+ tmpCurrent.push(tmpLine);
566
+ continue;
567
+ }
568
+
569
+ // Outside a fence, a blank line ends the current block
570
+ if (tmpLine.trim() === '')
571
+ {
572
+ if (tmpCurrent.length > 0)
573
+ {
574
+ tmpSegments.push({ Content: tmpCurrent.join('\n') });
575
+ tmpCurrent = [];
576
+ }
577
+ continue;
578
+ }
579
+
580
+ tmpCurrent.push(tmpLine);
581
+ }
582
+
583
+ // Push any trailing content
584
+ if (tmpCurrent.length > 0)
585
+ {
586
+ tmpSegments.push({ Content: tmpCurrent.join('\n') });
587
+ }
588
+
589
+ return tmpSegments.length > 0 ? tmpSegments : [{ Content: '' }];
590
+ }
591
+
592
+ // Depth 2+: split at headings of that level or higher.
593
+ // A heading like "## Foo" is depth 2. We split when we see a
594
+ // heading whose depth is <= the configured depth.
595
+ let tmpHeadingPattern = new RegExp('^(#{1,' + tmpDepth + '})\\s');
596
+ let tmpLines = pContent.split('\n');
597
+ let tmpSegments = [];
598
+ let tmpCurrent = [];
599
+
600
+ for (let i = 0; i < tmpLines.length; i++)
601
+ {
602
+ let tmpLine = tmpLines[i];
603
+
604
+ if (tmpHeadingPattern.test(tmpLine.trim()) && tmpCurrent.length > 0)
605
+ {
606
+ tmpSegments.push({ Content: tmpCurrent.join('\n') });
607
+ tmpCurrent = [];
608
+ }
609
+
610
+ tmpCurrent.push(tmpLine);
611
+ }
612
+
613
+ if (tmpCurrent.length > 0)
614
+ {
615
+ tmpSegments.push({ Content: tmpCurrent.join('\n') });
616
+ }
617
+
618
+ return tmpSegments.length > 0 ? tmpSegments : [{ Content: '' }];
619
+ }
620
+
621
+ /**
622
+ * Navigate to a file for editing.
623
+ *
624
+ * Automatically selects the markdown editor for .md files, the code
625
+ * editor (with highlight.js) for text files, or a binary preview
626
+ * card for images, archives, and other non-editable files.
627
+ *
628
+ * @param {string} pFilePath - The relative path of the file to edit
629
+ */
630
+ navigateToFile(pFilePath)
631
+ {
632
+ if (!pFilePath)
633
+ {
634
+ return;
635
+ }
636
+
637
+ let tmpSelf = this;
638
+
639
+ // Determine which editor to use before fetching content
640
+ let tmpEditorType = this.getEditorTypeForFile(pFilePath);
641
+
642
+ this.pict.AppData.ContentEditor.SaveStatus = '';
643
+ this.pict.AppData.ContentEditor.SaveStatusClass = '';
644
+
645
+ // Update the hash without triggering resolveHash again
646
+ window.location.hash = '#/edit/' + pFilePath;
647
+
648
+ // Set the current file and editor type
649
+ this.pict.AppData.ContentEditor.CurrentFile = pFilePath;
650
+ this.pict.AppData.ContentEditor.IsDirty = false;
651
+ this.pict.AppData.ContentEditor.ActiveEditor = tmpEditorType;
652
+
653
+ // Clean up existing editors
654
+ this._cleanupEditors();
655
+
656
+ // Re-render top bar
657
+ this.pict.views['ContentEditor-TopBar'].render();
658
+
659
+ // Binary files: show preview card without loading content
660
+ if (tmpEditorType === 'binary')
661
+ {
662
+ this._showBinaryPreview(pFilePath);
663
+ this.updateStats();
664
+ return;
665
+ }
666
+
667
+ // Text files: load content from the server
668
+ let tmpProvider = this.pict.providers['ContentEditor-Provider'];
669
+
670
+ this.pict.AppData.ContentEditor.IsLoading = true;
671
+
672
+ tmpProvider.loadFile(pFilePath, (pError, pContent) =>
673
+ {
674
+ tmpSelf.pict.AppData.ContentEditor.IsLoading = false;
675
+
676
+ if (pError)
677
+ {
678
+ tmpSelf.pict.AppData.ContentEditor.SaveStatus = 'Error loading file: ' + pError;
679
+ tmpSelf.pict.AppData.ContentEditor.SaveStatusClass = 'content-editor-status-error';
680
+ tmpSelf.pict.views['ContentEditor-TopBar'].render();
681
+ return;
682
+ }
683
+
684
+ if (tmpEditorType === 'markdown')
685
+ {
686
+ // Markdown editor: uses segments (auto-segment if enabled)
687
+ tmpSelf.pict.AppData.ContentEditor.Document.Segments = tmpSelf.segmentMarkdownContent(pContent);
688
+
689
+ let tmpEditorView = tmpSelf.pict.views['ContentEditor-MarkdownEditor'];
690
+ if (tmpEditorView)
691
+ {
692
+ tmpEditorView.render();
693
+ tmpEditorView.marshalToView();
694
+
695
+ // Apply the Auto Content Preview setting via the
696
+ // library's own toggle so the per-segment button
697
+ // continues to work.
698
+ tmpEditorView.togglePreview(tmpSelf.pict.AppData.ContentEditor.AutoContentPreview);
699
+
700
+ // Apply the Editing Controls setting (line numbers
701
+ // and right sidebar) via the library's toggleControls.
702
+ tmpEditorView.toggleControls(tmpSelf.pict.AppData.ContentEditor.MarkdownEditingControls);
703
+ }
704
+
705
+ tmpSelf.updateStats();
706
+ }
707
+ else
708
+ {
709
+ // Code editor: uses CodeContent
710
+ tmpSelf.pict.AppData.ContentEditor.CodeContent = pContent;
711
+
712
+ // Detect language from file extension
713
+ let tmpExtension = pFilePath.replace(/^.*\./, '').toLowerCase();
714
+ let tmpLanguage = libViewCodeEditor.getLanguageForExtension
715
+ ? libViewCodeEditor.getLanguageForExtension(tmpExtension)
716
+ : (libViewCodeEditor.ExtensionLanguageMap[tmpExtension] || 'plaintext');
717
+
718
+ let tmpCodeEditorView = tmpSelf.pict.views['ContentEditor-CodeEditor'];
719
+ if (tmpCodeEditorView)
720
+ {
721
+ tmpCodeEditorView.initialRenderComplete = false;
722
+ tmpCodeEditorView._language = tmpLanguage;
723
+
724
+ // Suppress the dirty signal from the initial content push
725
+ tmpCodeEditorView._suppressNextDirty = true;
726
+ tmpCodeEditorView.render();
727
+ tmpCodeEditorView.marshalToView();
728
+
729
+ // Apply Code Word Wrap setting
730
+ if (tmpSelf.pict.AppData.ContentEditor.CodeWordWrap && tmpCodeEditorView._editorElement)
731
+ {
732
+ tmpCodeEditorView._editorElement.style.whiteSpace = 'pre-wrap';
733
+ tmpCodeEditorView._editorElement.style.overflowWrap = 'break-word';
734
+ }
735
+ }
736
+
737
+ tmpSelf.updateStats();
738
+ }
739
+ });
740
+ }
741
+
742
+ /**
743
+ * Save the currently edited file.
744
+ */
745
+ saveCurrentFile()
746
+ {
747
+ let tmpFilePath = this.pict.AppData.ContentEditor.CurrentFile;
748
+ if (!tmpFilePath)
749
+ {
750
+ return;
751
+ }
752
+
753
+ let tmpProvider = this.pict.providers['ContentEditor-Provider'];
754
+ let tmpSelf = this;
755
+
756
+ let tmpContent = '';
757
+ let tmpActiveEditor = this.pict.AppData.ContentEditor.ActiveEditor;
758
+
759
+ if (tmpActiveEditor === 'code')
760
+ {
761
+ // Marshal content from the code editor
762
+ let tmpCodeEditorView = this.pict.views['ContentEditor-CodeEditor'];
763
+ if (tmpCodeEditorView)
764
+ {
765
+ tmpCodeEditorView.marshalFromView();
766
+ }
767
+ tmpContent = this.pict.AppData.ContentEditor.CodeContent || '';
768
+ }
769
+ else
770
+ {
771
+ // Marshal content from the markdown editor
772
+ let tmpEditorView = this.pict.views['ContentEditor-MarkdownEditor'];
773
+ if (tmpEditorView)
774
+ {
775
+ tmpEditorView.marshalFromView();
776
+ }
777
+
778
+ // Combine all segments into a single content string
779
+ let tmpSegments = this.pict.AppData.ContentEditor.Document.Segments;
780
+ if (tmpSegments && tmpSegments.length > 0)
781
+ {
782
+ let tmpParts = [];
783
+ for (let i = 0; i < tmpSegments.length; i++)
784
+ {
785
+ tmpParts.push(tmpSegments[i].Content || '');
786
+ }
787
+ tmpContent = tmpParts.join('\n\n');
788
+ }
789
+ }
790
+
791
+ this.pict.AppData.ContentEditor.IsSaving = true;
792
+ this.pict.AppData.ContentEditor.SaveStatus = 'Saving...';
793
+ this.pict.AppData.ContentEditor.SaveStatusClass = 'content-editor-status-saving';
794
+ this.pict.views['ContentEditor-TopBar'].render();
795
+
796
+ tmpProvider.saveFile(tmpFilePath, tmpContent, (pError) =>
797
+ {
798
+ tmpSelf.pict.AppData.ContentEditor.IsSaving = false;
799
+
800
+ if (pError)
801
+ {
802
+ tmpSelf.pict.AppData.ContentEditor.SaveStatus = 'Error: ' + pError;
803
+ tmpSelf.pict.AppData.ContentEditor.SaveStatusClass = 'content-editor-status-error';
804
+ }
805
+ else
806
+ {
807
+ tmpSelf.pict.AppData.ContentEditor.IsDirty = false;
808
+ tmpSelf.pict.AppData.ContentEditor.SaveStatus = 'Saved';
809
+ tmpSelf.pict.AppData.ContentEditor.SaveStatusClass = 'content-editor-status-saved';
810
+
811
+ // Refresh the file list after saving
812
+ tmpSelf.loadFileList();
813
+
814
+ // Clear the save status after a delay
815
+ setTimeout(() =>
816
+ {
817
+ if (tmpSelf.pict.AppData.ContentEditor.SaveStatus === 'Saved')
818
+ {
819
+ tmpSelf.pict.AppData.ContentEditor.SaveStatus = '';
820
+ tmpSelf.pict.AppData.ContentEditor.SaveStatusClass = '';
821
+ tmpSelf.pict.views['ContentEditor-TopBar'].render();
822
+ }
823
+ }, 3000);
824
+ }
825
+
826
+ tmpSelf.pict.views['ContentEditor-TopBar'].render();
827
+ });
828
+ }
829
+
830
+ /**
831
+ * Close the currently open file.
832
+ *
833
+ * If the document has unsaved changes, shows a confirmation dialog.
834
+ * Otherwise closes immediately.
835
+ */
836
+ closeCurrentFile()
837
+ {
838
+ if (!this.pict.AppData.ContentEditor.CurrentFile)
839
+ {
840
+ return;
841
+ }
842
+
843
+ if (this.pict.AppData.ContentEditor.IsDirty)
844
+ {
845
+ this._showCloseConfirmation();
846
+ return;
847
+ }
848
+
849
+ this._doCloseFile();
850
+ }
851
+
852
+ /**
853
+ * Perform the actual close: reset editor state to the welcome screen.
854
+ */
855
+ _doCloseFile()
856
+ {
857
+ this._hideCloseConfirmation();
858
+
859
+ this._cleanupEditors();
860
+
861
+ this.pict.AppData.ContentEditor.CurrentFile = '';
862
+ this.pict.AppData.ContentEditor.ActiveEditor = 'markdown';
863
+ this.pict.AppData.ContentEditor.IsDirty = false;
864
+ this.pict.AppData.ContentEditor.SaveStatus = '';
865
+ this.pict.AppData.ContentEditor.SaveStatusClass = '';
866
+ this.pict.AppData.ContentEditor.Document.Segments = [{ Content: '' }];
867
+ this.pict.AppData.ContentEditor.CodeContent = '';
868
+
869
+ // Clear the hash
870
+ window.location.hash = '';
871
+
872
+ // Re-render top bar (hides save/close buttons)
873
+ this.pict.views['ContentEditor-TopBar'].render();
874
+
875
+ // Show the welcome message
876
+ let tmpEditorContainer = this.pict.ContentAssignment.getElement('#ContentEditor-Editor-Container');
877
+ if (tmpEditorContainer && tmpEditorContainer[0])
878
+ {
879
+ tmpEditorContainer[0].innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#8A7F72;font-size:1.1em;">Select a file from the sidebar to begin editing</div>';
880
+ }
881
+
882
+ this.updateStats();
883
+ }
884
+
885
+ /**
886
+ * Confirm closing a file with unsaved changes (discard and close).
887
+ */
888
+ confirmCloseFile()
889
+ {
890
+ this._doCloseFile();
891
+ }
892
+
893
+ /**
894
+ * Cancel the close confirmation dialog.
895
+ */
896
+ cancelCloseFile()
897
+ {
898
+ this._hideCloseConfirmation();
899
+ }
900
+
901
+ /**
902
+ * Show the unsaved-changes confirmation overlay.
903
+ */
904
+ _showCloseConfirmation()
905
+ {
906
+ let tmpOverlay = document.getElementById('ContentEditor-ConfirmOverlay');
907
+ if (tmpOverlay)
908
+ {
909
+ tmpOverlay.classList.add('open');
910
+ }
911
+
912
+ // Set up keyboard listener for Y/N/Esc
913
+ if (!this._confirmKeyHandler)
914
+ {
915
+ let tmpSelf = this;
916
+ this._confirmKeyHandler = (pEvent) =>
917
+ {
918
+ let tmpKey = pEvent.key.toLowerCase();
919
+ if (tmpKey === 'y')
920
+ {
921
+ pEvent.preventDefault();
922
+ tmpSelf.confirmCloseFile();
923
+ }
924
+ else if (tmpKey === 'n' || pEvent.key === 'Escape')
925
+ {
926
+ pEvent.preventDefault();
927
+ tmpSelf.cancelCloseFile();
928
+ }
929
+ };
930
+ }
931
+
932
+ window.addEventListener('keydown', this._confirmKeyHandler);
933
+ }
934
+
935
+ /**
936
+ * Hide the unsaved-changes confirmation overlay and remove the keyboard listener.
937
+ */
938
+ _hideCloseConfirmation()
939
+ {
940
+ let tmpOverlay = document.getElementById('ContentEditor-ConfirmOverlay');
941
+ if (tmpOverlay)
942
+ {
943
+ tmpOverlay.classList.remove('open');
944
+ }
945
+
946
+ if (this._confirmKeyHandler)
947
+ {
948
+ window.removeEventListener('keydown', this._confirmKeyHandler);
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Create a new file.
954
+ *
955
+ * @param {string} pFilePath - The path for the new file
956
+ */
957
+ createNewFile(pFilePath)
958
+ {
959
+ if (!pFilePath)
960
+ {
961
+ return;
962
+ }
963
+
964
+ // Only add .md extension if the user did not provide any extension
965
+ let tmpBaseName = pFilePath.replace(/^.*\//, '');
966
+ if (tmpBaseName.indexOf('.') < 0)
967
+ {
968
+ pFilePath = pFilePath + '.md';
969
+ }
970
+
971
+ let tmpProvider = this.pict.providers['ContentEditor-Provider'];
972
+ let tmpSelf = this;
973
+
974
+ // Generate sensible default content based on file type
975
+ let tmpDefaultContent = '';
976
+ if (pFilePath.endsWith('.md'))
977
+ {
978
+ tmpDefaultContent = '# ' + pFilePath.replace(/\.[^.]+$/, '').replace(/^.*\//, '') + '\n\n';
979
+ }
980
+ else
981
+ {
982
+ tmpDefaultContent = '// ' + pFilePath.replace(/^.*\//, '') + '\n';
983
+ }
984
+
985
+ tmpProvider.saveFile(pFilePath, tmpDefaultContent, (pError) =>
986
+ {
987
+ if (!pError)
988
+ {
989
+ // Reload the file list and navigate to the new file
990
+ tmpSelf.loadFileList(null, () =>
991
+ {
992
+ tmpSelf.navigateToFile(pFilePath);
993
+ });
994
+ }
995
+ });
996
+ }
997
+
998
+ /**
999
+ * Prompt the user for a new file name and create it.
1000
+ */
1001
+ promptNewFile()
1002
+ {
1003
+ let tmpFileName = prompt('Enter a name for the new file (e.g. my-page.md, script.js, style.css):');
1004
+ if (tmpFileName && tmpFileName.trim())
1005
+ {
1006
+ this.createNewFile(tmpFileName.trim());
1007
+ }
1008
+ }
1009
+
1010
+ /**
1011
+ * Handle F4 / Cmd+Shift+T: context-aware topic creation.
1012
+ *
1013
+ * If the markdown editor is active and has focus, creates a new
1014
+ * topic linked to the current file and cursor line, then switches
1015
+ * to the Topics tab with the new entry in edit mode.
1016
+ *
1017
+ * Otherwise, just toggles the Topics sidebar tab.
1018
+ */
1019
+ handleF4TopicAction()
1020
+ {
1021
+ let tmpLayoutView = this.pict.views['ContentEditor-Layout'];
1022
+ let tmpTopicsView = this.pict.views['ContentEditor-Topics'];
1023
+
1024
+ if (!tmpLayoutView || !tmpTopicsView)
1025
+ {
1026
+ return;
1027
+ }
1028
+
1029
+ let tmpSettings = this.pict.AppData.ContentEditor;
1030
+ let tmpActiveEditor = tmpSettings.ActiveEditor;
1031
+ let tmpCurrentFile = tmpSettings.CurrentFile;
1032
+
1033
+ // Check if we're in the markdown editor with a file open
1034
+ let tmpInMarkdownEditor = (tmpActiveEditor === 'markdown' && tmpCurrentFile);
1035
+ let tmpLineNumber = 0;
1036
+ let tmpFoundFocus = false;
1037
+
1038
+ if (tmpInMarkdownEditor)
1039
+ {
1040
+ // Try to get the cursor line from the focused CodeMirror editor
1041
+ let tmpEditorView = this.pict.views['ContentEditor-MarkdownEditor'];
1042
+ if (tmpEditorView && tmpEditorView._segmentEditors)
1043
+ {
1044
+ let tmpRunningLines = 0;
1045
+ for (let tmpKey in tmpEditorView._segmentEditors)
1046
+ {
1047
+ let tmpEditor = tmpEditorView._segmentEditors[tmpKey];
1048
+ if (tmpEditor && tmpEditor.hasFocus)
1049
+ {
1050
+ let tmpPos = tmpEditor.state.selection.main.head;
1051
+ let tmpLine = tmpEditor.state.doc.lineAt(tmpPos);
1052
+ tmpLineNumber = tmpRunningLines + tmpLine.number;
1053
+ tmpFoundFocus = true;
1054
+ break;
1055
+ }
1056
+ if (tmpEditor && tmpEditor.state)
1057
+ {
1058
+ tmpRunningLines += tmpEditor.state.doc.lines;
1059
+ }
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ // Expand sidebar if collapsed
1065
+ if (tmpSettings.SidebarCollapsed)
1066
+ {
1067
+ tmpLayoutView.toggleSidebar();
1068
+ }
1069
+
1070
+ // Switch to the Topics tab
1071
+ tmpLayoutView.switchSidebarTab('topics');
1072
+
1073
+ // If we found a focused editor, create a topic from context
1074
+ if (tmpFoundFocus && tmpCurrentFile)
1075
+ {
1076
+ // Extract the document's first biggest heading for a default title
1077
+ let tmpDefaultTitle = this._extractFirstHeading();
1078
+
1079
+ let tmpTopicData =
1080
+ {
1081
+ TopicCode: 'New-Topic',
1082
+ TopicHelpFilePath: tmpCurrentFile,
1083
+ TopicTitle: tmpDefaultTitle || 'New Topic'
1084
+ };
1085
+
1086
+ if (tmpLineNumber > 0)
1087
+ {
1088
+ tmpTopicData.RelevantMarkdownLine = tmpLineNumber;
1089
+ }
1090
+
1091
+ tmpTopicsView.addTopic(tmpTopicData);
1092
+ }
1093
+ }
1094
+
1095
+ /**
1096
+ * Extract the first (highest-level) heading from the markdown
1097
+ * content currently loaded in the editor.
1098
+ *
1099
+ * Scans all segment editors for heading lines (# ... through
1100
+ * ###### ...) and returns the text of the highest-level one
1101
+ * found (preferring H1, then H2, etc.). If multiple headings
1102
+ * share the same level, the first one wins.
1103
+ *
1104
+ * @returns {string} The heading text, or '' if none found
1105
+ */
1106
+ _extractFirstHeading()
1107
+ {
1108
+ let tmpEditorView = this.pict.views['ContentEditor-MarkdownEditor'];
1109
+ if (!tmpEditorView || !tmpEditorView._segmentEditors)
1110
+ {
1111
+ return '';
1112
+ }
1113
+
1114
+ let tmpBestLevel = 7; // lower is better (1 = H1)
1115
+ let tmpBestText = '';
1116
+
1117
+ for (let tmpKey in tmpEditorView._segmentEditors)
1118
+ {
1119
+ let tmpEditor = tmpEditorView._segmentEditors[tmpKey];
1120
+ if (!tmpEditor || !tmpEditor.state || !tmpEditor.state.doc)
1121
+ {
1122
+ continue;
1123
+ }
1124
+
1125
+ let tmpDoc = tmpEditor.state.doc;
1126
+ for (let i = 1; i <= tmpDoc.lines; i++)
1127
+ {
1128
+ let tmpLine = tmpDoc.line(i).text;
1129
+ let tmpMatch = tmpLine.match(/^(#{1,6})\s+(.+)/);
1130
+ if (tmpMatch)
1131
+ {
1132
+ let tmpLevel = tmpMatch[1].length;
1133
+ if (tmpLevel < tmpBestLevel)
1134
+ {
1135
+ tmpBestLevel = tmpLevel;
1136
+ tmpBestText = tmpMatch[2].trim();
1137
+
1138
+ // Can't do better than H1
1139
+ if (tmpBestLevel === 1)
1140
+ {
1141
+ return tmpBestText;
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ return tmpBestText;
1149
+ }
1150
+
1151
+ /**
1152
+ * Update the document stats display (lines, words, chars).
1153
+ *
1154
+ * Reads directly from the active editor instances so there is no
1155
+ * need to marshal first. CodeMirror exposes line and character
1156
+ * counts on its document model at near-zero cost.
1157
+ */
1158
+ updateStats()
1159
+ {
1160
+ let tmpStatsEl = document.getElementById('ContentEditor-Stats');
1161
+ if (!tmpStatsEl)
1162
+ {
1163
+ return;
1164
+ }
1165
+
1166
+ let tmpActiveEditor = this.pict.AppData.ContentEditor.ActiveEditor;
1167
+ let tmpLines = 0;
1168
+ let tmpChars = 0;
1169
+ let tmpWords = 0;
1170
+
1171
+ if (tmpActiveEditor === 'markdown')
1172
+ {
1173
+ let tmpEditorView = this.pict.views['ContentEditor-MarkdownEditor'];
1174
+ if (tmpEditorView && tmpEditorView._segmentEditors)
1175
+ {
1176
+ for (let tmpKey in tmpEditorView._segmentEditors)
1177
+ {
1178
+ let tmpEditor = tmpEditorView._segmentEditors[tmpKey];
1179
+ if (tmpEditor && tmpEditor.state && tmpEditor.state.doc)
1180
+ {
1181
+ tmpLines += tmpEditor.state.doc.lines;
1182
+ tmpChars += tmpEditor.state.doc.length;
1183
+ let tmpText = tmpEditor.state.doc.toString();
1184
+ let tmpMatches = tmpText.match(/\S+/g);
1185
+ if (tmpMatches)
1186
+ {
1187
+ tmpWords += tmpMatches.length;
1188
+ }
1189
+ }
1190
+ }
1191
+ }
1192
+ }
1193
+ else if (tmpActiveEditor === 'code')
1194
+ {
1195
+ let tmpCodeEditorView = this.pict.views['ContentEditor-CodeEditor'];
1196
+ if (tmpCodeEditorView && tmpCodeEditorView.codeJar)
1197
+ {
1198
+ let tmpText = tmpCodeEditorView.codeJar.toString();
1199
+ tmpChars = tmpText.length;
1200
+ tmpLines = tmpText.split('\n').length;
1201
+ let tmpMatches = tmpText.match(/\S+/g);
1202
+ if (tmpMatches)
1203
+ {
1204
+ tmpWords = tmpMatches.length;
1205
+ }
1206
+ }
1207
+ }
1208
+ else
1209
+ {
1210
+ // Binary or no file — clear stats
1211
+ tmpStatsEl.textContent = '';
1212
+ return;
1213
+ }
1214
+
1215
+ tmpStatsEl.textContent = tmpLines + ' lines \u00B7 ' + tmpWords + ' words \u00B7 ' + tmpChars + ' chars';
1216
+ }
1217
+
1218
+ /**
1219
+ * Mark the document as dirty (unsaved changes).
1220
+ */
1221
+ markDirty()
1222
+ {
1223
+ if (!this.pict.AppData.ContentEditor.IsDirty)
1224
+ {
1225
+ this.pict.AppData.ContentEditor.IsDirty = true;
1226
+ this.pict.views['ContentEditor-TopBar'].render();
1227
+ }
1228
+ }
1229
+
1230
+ /**
1231
+ * The localStorage key used for persisting editor settings.
1232
+ */
1233
+ get _settingsKey()
1234
+ {
1235
+ return 'retold-content-editor-settings';
1236
+ }
1237
+
1238
+ /**
1239
+ * Persist the current editor settings to localStorage.
1240
+ */
1241
+ saveSettings()
1242
+ {
1243
+ if (typeof (window) === 'undefined' || !window.localStorage)
1244
+ {
1245
+ return;
1246
+ }
1247
+
1248
+ let tmpSettings = this.pict.AppData.ContentEditor;
1249
+
1250
+ let tmpData =
1251
+ {
1252
+ AutoSegmentMarkdown: tmpSettings.AutoSegmentMarkdown,
1253
+ AutoSegmentDepth: tmpSettings.AutoSegmentDepth,
1254
+ AutoContentPreview: tmpSettings.AutoContentPreview,
1255
+ MarkdownEditingControls: tmpSettings.MarkdownEditingControls,
1256
+ MarkdownWordWrap: tmpSettings.MarkdownWordWrap,
1257
+ CodeWordWrap: tmpSettings.CodeWordWrap,
1258
+ SidebarCollapsed: tmpSettings.SidebarCollapsed,
1259
+ SidebarWidth: tmpSettings.SidebarWidth,
1260
+ AutoPreviewImages: tmpSettings.AutoPreviewImages,
1261
+ AutoPreviewVideo: tmpSettings.AutoPreviewVideo,
1262
+ AutoPreviewAudio: tmpSettings.AutoPreviewAudio,
1263
+ ShowHiddenFiles: tmpSettings.ShowHiddenFiles,
1264
+ TopicsFilePath: tmpSettings.TopicsFilePath
1265
+ };
1266
+
1267
+ try
1268
+ {
1269
+ window.localStorage.setItem(this._settingsKey, JSON.stringify(tmpData));
1270
+ }
1271
+ catch (pError)
1272
+ {
1273
+ this.log.warn('Failed to save settings: ' + pError.message);
1274
+ }
1275
+ }
1276
+
1277
+ /**
1278
+ * Load editor settings from localStorage, overwriting the
1279
+ * current defaults for any keys that are present.
1280
+ */
1281
+ _loadSettings()
1282
+ {
1283
+ if (typeof (window) === 'undefined' || !window.localStorage)
1284
+ {
1285
+ return;
1286
+ }
1287
+
1288
+ try
1289
+ {
1290
+ let tmpRaw = window.localStorage.getItem(this._settingsKey);
1291
+ if (!tmpRaw)
1292
+ {
1293
+ return;
1294
+ }
1295
+
1296
+ let tmpStored = JSON.parse(tmpRaw);
1297
+ let tmpSettings = this.pict.AppData.ContentEditor;
1298
+
1299
+ if (typeof (tmpStored.AutoSegmentMarkdown) === 'boolean')
1300
+ {
1301
+ tmpSettings.AutoSegmentMarkdown = tmpStored.AutoSegmentMarkdown;
1302
+ }
1303
+ if (typeof (tmpStored.AutoSegmentDepth) === 'number')
1304
+ {
1305
+ tmpSettings.AutoSegmentDepth = tmpStored.AutoSegmentDepth;
1306
+ }
1307
+ if (typeof (tmpStored.AutoContentPreview) === 'boolean')
1308
+ {
1309
+ tmpSettings.AutoContentPreview = tmpStored.AutoContentPreview;
1310
+ }
1311
+ if (typeof (tmpStored.MarkdownEditingControls) === 'boolean')
1312
+ {
1313
+ tmpSettings.MarkdownEditingControls = tmpStored.MarkdownEditingControls;
1314
+ }
1315
+ if (typeof (tmpStored.MarkdownWordWrap) === 'boolean')
1316
+ {
1317
+ tmpSettings.MarkdownWordWrap = tmpStored.MarkdownWordWrap;
1318
+ }
1319
+ if (typeof (tmpStored.CodeWordWrap) === 'boolean')
1320
+ {
1321
+ tmpSettings.CodeWordWrap = tmpStored.CodeWordWrap;
1322
+ }
1323
+ if (typeof (tmpStored.SidebarCollapsed) === 'boolean')
1324
+ {
1325
+ tmpSettings.SidebarCollapsed = tmpStored.SidebarCollapsed;
1326
+ }
1327
+ if (typeof (tmpStored.SidebarWidth) === 'number')
1328
+ {
1329
+ tmpSettings.SidebarWidth = tmpStored.SidebarWidth;
1330
+ }
1331
+ if (typeof (tmpStored.AutoPreviewImages) === 'boolean')
1332
+ {
1333
+ tmpSettings.AutoPreviewImages = tmpStored.AutoPreviewImages;
1334
+ }
1335
+ if (typeof (tmpStored.AutoPreviewVideo) === 'boolean')
1336
+ {
1337
+ tmpSettings.AutoPreviewVideo = tmpStored.AutoPreviewVideo;
1338
+ }
1339
+ if (typeof (tmpStored.AutoPreviewAudio) === 'boolean')
1340
+ {
1341
+ tmpSettings.AutoPreviewAudio = tmpStored.AutoPreviewAudio;
1342
+ }
1343
+ if (typeof (tmpStored.ShowHiddenFiles) === 'boolean')
1344
+ {
1345
+ tmpSettings.ShowHiddenFiles = tmpStored.ShowHiddenFiles;
1346
+ }
1347
+ if (typeof (tmpStored.TopicsFilePath) === 'string')
1348
+ {
1349
+ tmpSettings.TopicsFilePath = tmpStored.TopicsFilePath;
1350
+ }
1351
+ }
1352
+ catch (pError)
1353
+ {
1354
+ this.log.warn('Failed to load settings: ' + pError.message);
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ module.exports = ContentEditorApplication;
1360
+
1361
+ module.exports.default_configuration = require('./Pict-Application-ContentEditor-Configuration.json');