mdv-live 0.3.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,1883 @@
1
+ /**
2
+ * MDV - Markdown Viewer Frontend
3
+ * Modular application structure
4
+ */
5
+ (function() {
6
+ 'use strict';
7
+
8
+ // ============================================================
9
+ // Constants
10
+ // ============================================================
11
+
12
+ const STORAGE_KEYS = {
13
+ THEME: 'mdv-theme',
14
+ SIDEBAR_WIDTH: 'mdv-sidebar-width'
15
+ };
16
+
17
+ const HLJS_THEMES = {
18
+ light: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css',
19
+ dark: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css'
20
+ };
21
+
22
+ const MERMAID_THEMES = {
23
+ light: {
24
+ theme: 'default',
25
+ variables: {
26
+ primaryColor: '#0066cc',
27
+ primaryTextColor: '#1a1a1a',
28
+ primaryBorderColor: '#d0d0d0',
29
+ lineColor: '#6a6a6a',
30
+ secondaryColor: '#e8e8e8',
31
+ tertiaryColor: '#f5f5f5'
32
+ }
33
+ },
34
+ dark: {
35
+ theme: 'dark',
36
+ variables: {
37
+ primaryColor: '#89b4fa',
38
+ primaryTextColor: '#cdd6f4',
39
+ primaryBorderColor: '#45475a',
40
+ lineColor: '#6c7086',
41
+ secondaryColor: '#313244',
42
+ tertiaryColor: '#181825'
43
+ }
44
+ }
45
+ };
46
+
47
+ const FILE_ICONS = {
48
+ markdown: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
49
+ python: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.373 0 5.5 2.875 5.5 2.875v2.5h6.5v.75H3.857S0 5.5 0 12s3.357 6.375 3.357 6.375h2.143v-3.063s-.125-3.312 3.25-3.312h5.5s3.25.063 3.25-3.125v-4.75S18 0 12 0zm-2.5 1.688a.937.937 0 110 1.874.937.937 0 010-1.874z"/></svg>',
50
+ javascript: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h24v24H0V0zm22.034 18.276c-.175-1.095-.888-2.015-3.003-2.873-.736-.345-1.554-.585-1.797-1.14-.091-.33-.105-.51-.046-.705.15-.646.915-.84 1.515-.66.39.12.75.42.976.9 1.034-.676 1.034-.676 1.755-1.125-.27-.42-.405-.6-.586-.78-.63-.705-1.47-1.065-2.834-1.035l-.705.09c-.676.165-1.32.525-1.71 1.005-1.14 1.29-.81 3.54.6 4.47 1.394.935 3.434 1.14 3.69 2.025.255 1.05-.6 1.39-1.365 1.26-.9-.165-1.395-.75-1.935-1.71l-1.815.99c.21.6.555 1.035.885 1.365.885.885 2.07 1.185 3.305 1.125 1.38-.165 2.73-.735 3.09-2.355.165-.555.165-1.095.015-1.755l-.06.075z"/></svg>',
51
+ typescript: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M0 12v12h24V0H0v12zm19.341-.956c.61.152 1.074.423 1.501.865.221.236.549.666.575.77.008.03-1.036.73-1.668 1.123-.023.015-.115-.084-.217-.236-.31-.45-.633-.644-1.128-.678-.728-.05-1.196.331-1.192.967a.88.88 0 00.102.45c.16.331.458.53 1.39.933 1.719.74 2.454 1.227 2.911 1.92.51.773.625 2.008.278 2.926-.38 1.003-1.328 1.685-2.655 1.907-.411.073-1.386.062-1.828-.018-.964-.172-1.878-.648-2.442-1.273-.221-.243-.652-.88-.625-.925.011-.016.11-.077.22-.141.108-.061.511-.294.892-.515l.69-.4.145.214c.202.308.643.731.91.872.767.404 1.82.347 2.335-.118a.883.883 0 00.313-.72c0-.278-.035-.4-.18-.61-.186-.266-.567-.49-1.649-.96-1.238-.533-1.771-.864-2.259-1.39a3.165 3.165 0 01-.659-1.2c-.091-.339-.114-1.189-.042-1.531.255-1.2 1.158-2.031 2.461-2.278.423-.08 1.406-.05 1.821.053zm-5.634 1.002l.008.983H10.59v8.876H8.38v-8.876H5.258v-.964c0-.534.011-.98.026-.99.012-.016 1.913-.024 4.217-.02l4.195.012z"/></svg>',
52
+ json: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>',
53
+ yaml: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>',
54
+ html: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>',
55
+ css: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>',
56
+ image: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>',
57
+ pdf: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>',
58
+ text: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
59
+ config: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
60
+ shell: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>',
61
+ database: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>',
62
+ react: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 10.11c1.03 0 1.87.84 1.87 1.89 0 1-.84 1.85-1.87 1.85S10.13 13 10.13 12c0-1.05.84-1.89 1.87-1.89M7.37 20c.63.38 2.01-.2 3.6-1.7-.52-.59-1.03-1.23-1.51-1.9a22.7 22.7 0 01-2.4-.36c-.51 2.14-.32 3.61.31 3.96m.71-5.74l-.29-.51c-.11.29-.22.58-.29.86.27.06.57.11.88.16l-.3-.51m6.54-.76l.81-1.5-.81-1.5c-.3-.53-.62-1-.91-1.47C13.17 9 12.6 9 12 9s-1.17 0-1.71.03c-.29.47-.61.94-.91 1.47L8.57 12l.81 1.5c.3.53.62 1 .91 1.47.54.03 1.11.03 1.71.03s1.17 0 1.71-.03c.29-.47.61-.94.91-1.47M12 6.78c-.19.22-.39.45-.59.72h1.18c-.2-.27-.4-.5-.59-.72m0 10.44c.19-.22.39-.45.59-.72h-1.18c.2.27.4.5.59.72M16.62 4c-.62-.38-2 .2-3.59 1.7.52.59 1.03 1.23 1.51 1.9.82.08 1.63.2 2.4.36.51-2.14.32-3.61-.32-3.96m-.7 5.74l.29.51c.11-.29.22-.58.29-.86-.27-.06-.57-.11-.88-.16l.3.51m1.45-7.05c1.47.84 1.63 3.05 1.01 5.63 2.54.75 4.37 1.99 4.37 3.68 0 1.69-1.83 2.93-4.37 3.68.62 2.58.46 4.79-1.01 5.63-1.46.84-3.45-.12-5.37-1.95-1.92 1.83-3.91 2.79-5.38 1.95-1.46-.84-1.62-3.05-1-5.63-2.54-.75-4.37-1.99-4.37-3.68 0-1.69 1.83-2.93 4.37-3.68-.62-2.58-.46-4.79 1-5.63 1.47-.84 3.46.12 5.38 1.95 1.92-1.83 3.91-2.79 5.37-1.95M17.08 12c.34.75.64 1.5.89 2.26 2.1-.63 3.28-1.53 3.28-2.26 0-.73-1.18-1.63-3.28-2.26-.25.76-.55 1.51-.89 2.26M6.92 12c-.34-.75-.64-1.5-.89-2.26-2.1.63-3.28 1.53-3.28 2.26 0 .73 1.18 1.63 3.28 2.26.25-.76.55-1.51.89-2.26m9 2.26l-.3.51c.31-.05.61-.1.88-.16-.07-.28-.18-.57-.29-.86l-.29.51m-2.89 4.04c1.59 1.5 2.97 2.08 3.59 1.7.64-.35.83-1.82.32-3.96-.77.16-1.58.28-2.4.36-.48.67-.99 1.31-1.51 1.9M8.08 9.74l.3-.51c-.31.05-.61.1-.88.16.07.28.18.57.29.86l.29-.51m2.89-4.04C9.38 4.2 8 3.62 7.37 4c-.63.35-.82 1.82-.31 3.96a22.7 22.7 0 012.4-.36c.48-.67.99-1.31 1.51-1.9z"/></svg>',
63
+ vue: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 3h3.5L12 15l6.5-12H22L12 21 2 3zm4.5 0h3L12 8l2.5-5h3L12 12.5 6.5 3z"/></svg>',
64
+ video: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>',
65
+ audio: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" /></svg>',
66
+ archive: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>',
67
+ office: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
68
+ executable: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /></svg>',
69
+ binary: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2 1 3 3 3h10c2 0 3-1 3-3V7c0-2-1-3-3-3H7c-2 0-3 1-3 3z" /></svg>',
70
+ default: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>'
71
+ };
72
+
73
+ // ============================================================
74
+ // State
75
+ // ============================================================
76
+
77
+ const state = {
78
+ theme: localStorage.getItem(STORAGE_KEYS.THEME) || 'light',
79
+ sidebarWidth: parseInt(localStorage.getItem(STORAGE_KEYS.SIDEBAR_WIDTH)) || 280,
80
+ tabs: [],
81
+ activeTabIndex: -1,
82
+ ws: null,
83
+ isEditMode: false,
84
+ hasUnsavedChanges: false,
85
+ isResizing: false,
86
+ skipScrollRestore: false,
87
+ uploadTargetPath: '',
88
+ rootPath: ''
89
+ };
90
+
91
+ // ============================================================
92
+ // DOM Elements
93
+ // ============================================================
94
+
95
+ const elements = {
96
+ sidebar: document.getElementById('sidebar'),
97
+ sidebarToggle: document.getElementById('sidebarToggle'),
98
+ themeToggle: document.getElementById('themeToggle'),
99
+ printBtn: document.getElementById('printBtn'),
100
+ sunIcon: document.getElementById('sunIcon'),
101
+ moonIcon: document.getElementById('moonIcon'),
102
+ hljsTheme: document.getElementById('hljs-theme'),
103
+ fileTree: document.getElementById('fileTree'),
104
+ tabBar: document.getElementById('tabBar'),
105
+ content: document.getElementById('content'),
106
+ statusDot: document.getElementById('statusDot'),
107
+ statusText: document.getElementById('statusText'),
108
+ resizeHandle: document.getElementById('resizeHandle'),
109
+ editToggle: document.getElementById('editToggle'),
110
+ editLabel: document.getElementById('editLabel'),
111
+ editorStatus: document.getElementById('editorStatus'),
112
+ shutdownBtn: document.getElementById('shutdownBtn'),
113
+ // File browser elements
114
+ contextMenu: document.getElementById('contextMenu'),
115
+ dialogOverlay: document.getElementById('dialogOverlay'),
116
+ dialogTitle: document.getElementById('dialogTitle'),
117
+ dialogInput: document.getElementById('dialogInput'),
118
+ dialogMessage: document.getElementById('dialogMessage'),
119
+ dialogCancel: document.getElementById('dialogCancel'),
120
+ dialogConfirm: document.getElementById('dialogConfirm'),
121
+ uploadOverlay: document.getElementById('uploadOverlay'),
122
+ uploadFileName: document.getElementById('uploadFileName'),
123
+ uploadProgressFill: document.getElementById('uploadProgressFill'),
124
+ uploadProgressText: document.getElementById('uploadProgressText'),
125
+ fileInput: document.getElementById('fileInput')
126
+ };
127
+
128
+ // ============================================================
129
+ // Utilities
130
+ // ============================================================
131
+
132
+ function escapeHtml(text) {
133
+ const div = document.createElement('div');
134
+ div.textContent = text;
135
+ return div.innerHTML;
136
+ }
137
+
138
+ function getFileIcon(iconName) {
139
+ return FILE_ICONS[iconName] || FILE_ICONS.default;
140
+ }
141
+
142
+ // Scroll position utilities
143
+ function saveScrollPosition(element) {
144
+ return element.scrollTop;
145
+ }
146
+
147
+ function restoreScrollPosition(element, position) {
148
+ requestAnimationFrame(() => {
149
+ element.scrollTop = position;
150
+ });
151
+ }
152
+
153
+ // API utilities
154
+ async function apiRequest(url, options = {}) {
155
+ const response = await fetch(url, options);
156
+ const data = await response.json();
157
+ if (data.error || data.detail) {
158
+ throw new Error(data.error || data.detail);
159
+ }
160
+ return data;
161
+ }
162
+
163
+ async function apiPost(url, body) {
164
+ return apiRequest(url, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify(body)
168
+ });
169
+ }
170
+
171
+ // Tab path update utility (shared by rename and move)
172
+ function updateTabPaths(oldPath, newPath) {
173
+ let updated = false;
174
+ const newName = newPath.split('/').pop();
175
+ state.tabs.forEach(tab => {
176
+ if (tab.path === oldPath) {
177
+ tab.path = newPath;
178
+ tab.name = newName;
179
+ updated = true;
180
+ } else if (tab.path.startsWith(oldPath + '/')) {
181
+ tab.path = newPath + tab.path.substring(oldPath.length);
182
+ updated = true;
183
+ }
184
+ });
185
+ return updated;
186
+ }
187
+
188
+ // ============================================================
189
+ // Theme Management
190
+ // ============================================================
191
+
192
+ const ThemeManager = {
193
+ set(theme) {
194
+ state.theme = theme;
195
+ document.body.dataset.theme = theme;
196
+ localStorage.setItem(STORAGE_KEYS.THEME, theme);
197
+
198
+ const isLight = theme === 'light';
199
+ elements.sunIcon.style.display = isLight ? 'none' : 'block';
200
+ elements.moonIcon.style.display = isLight ? 'block' : 'none';
201
+ elements.hljsTheme.href = HLJS_THEMES[theme];
202
+
203
+ const mermaidConfig = MERMAID_THEMES[theme];
204
+ mermaid.initialize({
205
+ startOnLoad: false,
206
+ theme: mermaidConfig.theme,
207
+ themeVariables: mermaidConfig.variables
208
+ });
209
+ },
210
+
211
+ toggle() {
212
+ this.set(state.theme === 'dark' ? 'light' : 'dark');
213
+ if (state.activeTabIndex >= 0) {
214
+ const currentScroll = saveScrollPosition(elements.content);
215
+ TabManager.renderActive();
216
+ restoreScrollPosition(elements.content, currentScroll);
217
+ }
218
+ },
219
+
220
+ init() {
221
+ this.set(state.theme);
222
+ elements.themeToggle.addEventListener('click', () => this.toggle());
223
+ }
224
+ };
225
+
226
+ // ============================================================
227
+ // Sidebar Management
228
+ // ============================================================
229
+
230
+ const SidebarManager = {
231
+ toggle() {
232
+ elements.sidebar.classList.toggle('collapsed');
233
+ if (!elements.sidebar.classList.contains('collapsed')) {
234
+ elements.sidebar.style.width = state.sidebarWidth + 'px';
235
+ }
236
+ },
237
+
238
+ setWidth(width) {
239
+ if (width < 50) {
240
+ elements.sidebar.classList.add('collapsed');
241
+ } else {
242
+ elements.sidebar.classList.remove('collapsed');
243
+ elements.sidebar.style.width = width + 'px';
244
+ state.sidebarWidth = width;
245
+ localStorage.setItem(STORAGE_KEYS.SIDEBAR_WIDTH, width);
246
+ }
247
+ },
248
+
249
+ init() {
250
+ elements.sidebar.style.width = state.sidebarWidth + 'px';
251
+ elements.sidebarToggle.addEventListener('click', () => this.toggle());
252
+ }
253
+ };
254
+
255
+ // ============================================================
256
+ // Resize Handler
257
+ // ============================================================
258
+
259
+ const ResizeHandler = {
260
+ start() {
261
+ state.isResizing = true;
262
+ elements.resizeHandle.classList.add('active');
263
+ document.body.style.cursor = 'col-resize';
264
+ document.body.style.userSelect = 'none';
265
+ },
266
+
267
+ move(clientX) {
268
+ if (!state.isResizing) return;
269
+ if (clientX >= 0 && clientX <= 500) {
270
+ SidebarManager.setWidth(clientX);
271
+ }
272
+ },
273
+
274
+ end() {
275
+ if (state.isResizing) {
276
+ state.isResizing = false;
277
+ elements.resizeHandle.classList.remove('active');
278
+ document.body.style.cursor = '';
279
+ document.body.style.userSelect = '';
280
+ }
281
+ },
282
+
283
+ init() {
284
+ elements.resizeHandle.addEventListener('mousedown', () => this.start());
285
+ document.addEventListener('mousemove', (e) => this.move(e.clientX));
286
+ document.addEventListener('mouseup', () => this.end());
287
+ }
288
+ };
289
+
290
+ // ============================================================
291
+ // WebSocket Manager
292
+ // ============================================================
293
+
294
+ const WebSocketManager = {
295
+ connect() {
296
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
297
+ state.ws = new WebSocket(`${protocol}//${location.host}/ws`);
298
+
299
+ state.ws.onopen = async () => {
300
+ elements.statusDot.classList.remove('disconnected');
301
+ elements.statusText.textContent = 'Connected';
302
+ if (state.activeTabIndex >= 0) {
303
+ this.watchFile(state.tabs[state.activeTabIndex].path);
304
+ // 再接続時に最新データを取得
305
+ await refreshCurrentTab();
306
+ }
307
+ };
308
+
309
+ state.ws.onmessage = (event) => {
310
+ const data = JSON.parse(event.data);
311
+ if (data.type === 'file_update' && state.activeTabIndex >= 0) {
312
+ this.handleFileUpdate(data);
313
+ } else if (data.type === 'tree_update' && data.tree) {
314
+ FileTreeManager.update(data.tree);
315
+ }
316
+ };
317
+
318
+ state.ws.onclose = () => {
319
+ elements.statusDot.classList.add('disconnected');
320
+ elements.statusText.textContent = 'Disconnected';
321
+ setTimeout(() => this.connect(), 3000);
322
+ };
323
+
324
+ state.ws.onerror = () => state.ws.close();
325
+ },
326
+
327
+ watchFile(path) {
328
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
329
+ state.ws.send(JSON.stringify({ type: 'watch', path }));
330
+ }
331
+ },
332
+
333
+ handleFileUpdate(data) {
334
+ const tab = state.tabs[state.activeTabIndex];
335
+ if (data.fileType === 'image' && data.reload) {
336
+ ContentRenderer.renderImage(tab.imageUrl, tab.name);
337
+ return;
338
+ }
339
+ if (!data.content) return;
340
+
341
+ tab.content = data.content;
342
+ if (data.raw) {
343
+ tab.raw = data.raw;
344
+ }
345
+ // Update Marp flag
346
+ if (typeof data.isMarp !== 'undefined') {
347
+ tab.isMarp = data.isMarp;
348
+ }
349
+
350
+ if (state.isEditMode) {
351
+ if (!state.hasUnsavedChanges && data.raw) {
352
+ const textarea = document.getElementById('editorTextarea');
353
+ if (textarea) {
354
+ const currentScroll = saveScrollPosition(textarea);
355
+ textarea.value = data.raw;
356
+ restoreScrollPosition(textarea, currentScroll);
357
+ }
358
+ }
359
+ } else {
360
+ // Marp slides: preserve current slide position
361
+ if (tab.isMarp) {
362
+ // Update css if provided
363
+ if (data.css) tab.css = data.css;
364
+ ContentRenderer.renderMarp(data.content, tab.css);
365
+ } else {
366
+ const currentScroll = saveScrollPosition(elements.content);
367
+ ContentRenderer.render(data.content, data.fileType || tab.fileType);
368
+ restoreScrollPosition(elements.content, currentScroll);
369
+ }
370
+ }
371
+ }
372
+ };
373
+
374
+ // ============================================================
375
+ // File Tree Manager
376
+ // ============================================================
377
+
378
+ const FileTreeManager = {
379
+ async load(retries = 5) {
380
+ for (let i = 0; i < retries; i++) {
381
+ try {
382
+ const response = await fetch('/api/tree');
383
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
384
+ const tree = await response.json();
385
+ elements.fileTree.innerHTML = this.renderItems(tree);
386
+ return;
387
+ } catch (e) {
388
+ console.warn(`Failed to load tree (attempt ${i + 1}/${retries}):`, e);
389
+ if (i < retries - 1) {
390
+ await new Promise(r => setTimeout(r, 300 + 400 * i)); // 300, 700, 1100, 1500ms
391
+ }
392
+ }
393
+ }
394
+ // 最後の手段: ページに再読み込みボタンを表示
395
+ elements.fileTree.innerHTML = '<div style="padding: 16px; color: var(--text-muted);">読み込みに失敗しました。<br><button onclick="location.reload()" style="margin-top: 8px; cursor: pointer;">再読み込み</button></div>';
396
+ },
397
+
398
+ async update(tree) {
399
+ // 展開済みかつ読み込み済みのパスを保存
400
+ const expandedPaths = new Set();
401
+ document.querySelectorAll('.tree-item').forEach(item => {
402
+ const children = item.querySelector('.tree-children');
403
+ if (children && !children.classList.contains('collapsed') && item.dataset.loaded === 'true') {
404
+ expandedPaths.add(item.dataset.path);
405
+ }
406
+ });
407
+
408
+ elements.fileTree.innerHTML = this.renderItems(tree);
409
+
410
+ // 展開済みディレクトリを復元(子要素も再取得)
411
+ for (const path of expandedPaths) {
412
+ const item = document.querySelector(`.tree-item[data-path="${path}"]`);
413
+ if (item) {
414
+ const children = item.querySelector('.tree-children');
415
+ const chevron = item.querySelector('.chevron');
416
+
417
+ // 子要素を再取得
418
+ if (children && item.dataset.loaded !== 'true') {
419
+ await this.expandDirectory(path, children);
420
+ }
421
+
422
+ if (children) children.classList.remove('collapsed');
423
+ if (chevron) chevron.classList.add('expanded');
424
+ }
425
+ }
426
+
427
+ this.updateHighlight();
428
+ },
429
+
430
+ renderItems(items) {
431
+ if (!items || items.length === 0) return '';
432
+
433
+ return items.map(item => {
434
+ if (item.type === 'directory') {
435
+ return this.renderDirectory(item);
436
+ }
437
+ return this.renderFile(item);
438
+ }).join('');
439
+ },
440
+
441
+ renderDirectory(item) {
442
+ const loaded = item.loaded !== false;
443
+ return `
444
+ <div class="tree-item" data-path="${item.path}" data-loaded="${loaded}" draggable="true">
445
+ <div class="tree-item-content" onclick="MDV.toggleDirectory(this)">
446
+ <svg class="chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
447
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
448
+ </svg>
449
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
450
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
451
+ </svg>
452
+ <span class="name">${item.name}</span>
453
+ </div>
454
+ <div class="tree-children collapsed">${this.renderItems(item.children)}</div>
455
+ </div>
456
+ `;
457
+ },
458
+
459
+ async expandDirectory(path, childrenContainer) {
460
+ try {
461
+ const response = await fetch(`/api/tree/expand?path=${encodeURIComponent(path)}`);
462
+ const children = await response.json();
463
+ childrenContainer.innerHTML = this.renderItems(children);
464
+
465
+ // 親要素をloaded=trueに更新
466
+ const treeItem = childrenContainer.closest('.tree-item');
467
+ if (treeItem) {
468
+ treeItem.dataset.loaded = 'true';
469
+ }
470
+ } catch (e) {
471
+ console.error('Failed to expand directory:', e);
472
+ }
473
+ },
474
+
475
+ renderFile(item) {
476
+ const iconClass = item.icon ? `icon-${item.icon}` : '';
477
+ const iconSvg = getFileIcon(item.icon);
478
+ return `
479
+ <div class="tree-item" data-path="${item.path}" draggable="true">
480
+ <div class="tree-item-content" onclick="MDV.openFile('${item.path}')">
481
+ <span class="${iconClass}" style="margin-left: 22px; display: flex; align-items: center;">
482
+ ${iconSvg}
483
+ </span>
484
+ <span class="name">${item.name}</span>
485
+ </div>
486
+ </div>
487
+ `;
488
+ },
489
+
490
+ updateHighlight() {
491
+ document.querySelectorAll('.tree-item-content.active').forEach(el => {
492
+ el.classList.remove('active');
493
+ });
494
+ if (state.activeTabIndex >= 0) {
495
+ const path = state.tabs[state.activeTabIndex].path;
496
+ const el = document.querySelector(`.tree-item[data-path="${path}"] > .tree-item-content`);
497
+ if (el) el.classList.add('active');
498
+ }
499
+ }
500
+ };
501
+
502
+ // ============================================================
503
+ // Content Renderer
504
+ // ============================================================
505
+
506
+ // Marp state (module-level to persist across renders)
507
+ let marpCurrentSlide = 0;
508
+ let marpKeyHandler = null;
509
+
510
+ const ContentRenderer = {
511
+ render(htmlContent, fileType) {
512
+ // コードファイル(非markdown)は専用のスタイルを適用
513
+ const isCodeFile = fileType === 'code';
514
+ const containerClass = isCodeFile ? 'markdown-body code-view-container' : 'markdown-body';
515
+ elements.content.innerHTML = `<div class="${containerClass}">${htmlContent}</div>`;
516
+
517
+ elements.content.querySelectorAll('pre code').forEach(block => {
518
+ hljs.highlightElement(block);
519
+ });
520
+
521
+ if (fileType === 'markdown') {
522
+ this.renderMermaid();
523
+ }
524
+ },
525
+
526
+ renderMarp(htmlContent, css) {
527
+ // Clean up previous Marp handlers
528
+ this.cleanupMarp();
529
+
530
+ elements.content.classList.add('marp-viewer');
531
+
532
+ // Apply Marp CSS from marp-core (preserves exact structure for CSS selectors)
533
+ if (css) {
534
+ // Remove previous Marp style
535
+ const oldStyle = document.getElementById('marp-style');
536
+ if (oldStyle) oldStyle.remove();
537
+
538
+ // Add new Marp style with navigation overrides
539
+ const style = document.createElement('style');
540
+ style.id = 'marp-style';
541
+ // Add slide navigation CSS
542
+ const navOverrides = `
543
+ /* Marp slide navigation */
544
+ .marpit {
545
+ position: relative;
546
+ display: flex;
547
+ flex-direction: column;
548
+ align-items: center;
549
+ padding: 20px;
550
+ padding-bottom: 80px;
551
+ }
552
+ .marpit > svg[data-marpit-svg] {
553
+ display: none;
554
+ max-width: 100%;
555
+ height: auto;
556
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
557
+ border-radius: 4px;
558
+ }
559
+ .marpit > svg[data-marpit-svg].active {
560
+ display: block;
561
+ }
562
+ @media print {
563
+ .marpit {
564
+ padding: 0 !important;
565
+ background: transparent !important;
566
+ }
567
+ .marpit > svg[data-marpit-svg] {
568
+ display: block !important;
569
+ width: 100% !important;
570
+ height: auto !important;
571
+ max-width: none !important;
572
+ box-shadow: none !important;
573
+ border-radius: 0 !important;
574
+ page-break-after: always;
575
+ page-break-inside: avoid;
576
+ }
577
+ .marpit > svg[data-marpit-svg]:last-child {
578
+ page-break-after: avoid;
579
+ }
580
+ .marp-nav { display: none !important; }
581
+ }
582
+ `;
583
+ style.textContent = css + navOverrides;
584
+ document.head.appendChild(style);
585
+ }
586
+
587
+ elements.content.innerHTML = htmlContent;
588
+
589
+ // Add navigation controls to marpit container
590
+ const marpit = elements.content.querySelector('.marpit');
591
+ if (marpit) {
592
+ const nav = document.createElement('div');
593
+ nav.className = 'marp-nav';
594
+ nav.innerHTML = `
595
+ <button class="marp-prev" title="Previous (←)">
596
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
597
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
598
+ </svg>
599
+ </button>
600
+ <span class="slide-counter">1 / 1</span>
601
+ <button class="marp-next" title="Next (→)">
602
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
603
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
604
+ </svg>
605
+ </button>
606
+ `;
607
+ marpit.appendChild(nav);
608
+ }
609
+
610
+ // Initialize slide navigation
611
+ this.initMarpNavigation();
612
+
613
+ // Syntax highlight
614
+ elements.content.querySelectorAll('pre code').forEach(block => {
615
+ hljs.highlightElement(block);
616
+ });
617
+
618
+ // Mermaid
619
+ this.renderMermaid();
620
+ },
621
+
622
+ initMarpNavigation() {
623
+ // Marp uses svg[data-marpit-svg] for each slide
624
+ const slides = elements.content.querySelectorAll('.marpit > svg[data-marpit-svg]');
625
+ const counter = elements.content.querySelector('.slide-counter');
626
+ const prevBtn = elements.content.querySelector('.marp-prev');
627
+ const nextBtn = elements.content.querySelector('.marp-next');
628
+
629
+ if (slides.length === 0) return;
630
+
631
+ // Reset to first slide (or restore position if within bounds)
632
+ if (marpCurrentSlide >= slides.length) {
633
+ marpCurrentSlide = 0;
634
+ }
635
+
636
+ const showSlide = (index) => {
637
+ slides.forEach((slide, i) => {
638
+ slide.classList.toggle('active', i === index);
639
+ });
640
+ marpCurrentSlide = index;
641
+ if (counter) {
642
+ counter.textContent = `${index + 1} / ${slides.length}`;
643
+ }
644
+ if (prevBtn) prevBtn.disabled = index === 0;
645
+ if (nextBtn) nextBtn.disabled = index === slides.length - 1;
646
+ };
647
+
648
+ const nextSlide = () => {
649
+ if (marpCurrentSlide < slides.length - 1) {
650
+ showSlide(marpCurrentSlide + 1);
651
+ }
652
+ };
653
+
654
+ const prevSlide = () => {
655
+ if (marpCurrentSlide > 0) {
656
+ showSlide(marpCurrentSlide - 1);
657
+ }
658
+ };
659
+
660
+ // Show initial slide
661
+ showSlide(marpCurrentSlide);
662
+
663
+ // Button handlers
664
+ if (prevBtn) prevBtn.addEventListener('click', prevSlide);
665
+ if (nextBtn) nextBtn.addEventListener('click', nextSlide);
666
+
667
+ // Keyboard navigation
668
+ marpKeyHandler = (e) => {
669
+ // Don't handle if editing or in dialog
670
+ if (state.isEditMode || !elements.dialogOverlay.classList.contains('hidden')) {
671
+ return;
672
+ }
673
+ if (e.key === 'ArrowRight' || e.key === ' ') {
674
+ e.preventDefault();
675
+ nextSlide();
676
+ } else if (e.key === 'ArrowLeft') {
677
+ e.preventDefault();
678
+ prevSlide();
679
+ }
680
+ };
681
+ document.addEventListener('keydown', marpKeyHandler);
682
+ },
683
+
684
+ cleanupMarp() {
685
+ elements.content.classList.remove('marp-viewer');
686
+ if (marpKeyHandler) {
687
+ document.removeEventListener('keydown', marpKeyHandler);
688
+ marpKeyHandler = null;
689
+ }
690
+ },
691
+
692
+ async renderMermaid() {
693
+ const blocks = elements.content.querySelectorAll('code.language-mermaid');
694
+ for (let i = 0; i < blocks.length; i++) {
695
+ const block = blocks[i];
696
+ const pre = block.parentElement;
697
+ const mermaidCode = block.textContent;
698
+ const div = document.createElement('div');
699
+ div.className = 'mermaid';
700
+
701
+ try {
702
+ const { svg } = await mermaid.render(`mermaid-${Date.now()}-${i}`, mermaidCode);
703
+ div.innerHTML = svg;
704
+ pre.replaceWith(div);
705
+ } catch (e) {
706
+ console.error('Mermaid error:', e);
707
+ }
708
+ }
709
+ },
710
+
711
+ renderImage(imageUrl, name) {
712
+ const url = imageUrl + '&t=' + Date.now();
713
+ elements.content.innerHTML = `
714
+ <div class="image-preview">
715
+ <img src="${url}" alt="${name}" />
716
+ <div class="image-info">${name}</div>
717
+ </div>
718
+ `;
719
+ },
720
+
721
+ renderPDF(pdfUrl, name) {
722
+ const url = pdfUrl + '&t=' + Date.now();
723
+ elements.content.style.padding = '0';
724
+ elements.content.innerHTML = `
725
+ <div class="pdf-viewer">
726
+ <iframe src="${url}" title="${name}"></iframe>
727
+ </div>
728
+ `;
729
+ },
730
+
731
+ renderVideo(mediaUrl, name) {
732
+ elements.content.innerHTML = `
733
+ <div class="video-preview">
734
+ <video controls>
735
+ <source src="${mediaUrl}" type="video/mp4">
736
+ お使いのブラウザは動画再生に対応していません。
737
+ </video>
738
+ <div class="media-info">${name}</div>
739
+ </div>
740
+ `;
741
+ },
742
+
743
+ renderAudio(mediaUrl, name) {
744
+ elements.content.innerHTML = `
745
+ <div class="audio-preview">
746
+ <audio controls>
747
+ <source src="${mediaUrl}">
748
+ お使いのブラウザは音声再生に対応していません。
749
+ </audio>
750
+ <div class="media-info">${name}</div>
751
+ </div>
752
+ `;
753
+ },
754
+
755
+ renderBinary(name, icon) {
756
+ const iconSvg = getFileIcon(icon);
757
+ elements.content.innerHTML = `
758
+ <div class="binary-preview">
759
+ <div class="binary-icon">${iconSvg}</div>
760
+ <div class="binary-info">${name}</div>
761
+ </div>
762
+ `;
763
+ },
764
+
765
+ showWelcome() {
766
+ elements.content.innerHTML = `
767
+ <div class="welcome">
768
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
769
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
770
+ </svg>
771
+ <h2>Select a file</h2>
772
+ <p>Choose a file from the sidebar</p>
773
+ <p><kbd>Cmd+E</kbd> Edit &nbsp; <kbd>Cmd+S</kbd> Save &nbsp; <kbd>Cmd+P</kbd> PDF</p>
774
+ </div>
775
+ `;
776
+ }
777
+ };
778
+
779
+ // ============================================================
780
+ // Tab Manager
781
+ // ============================================================
782
+
783
+ const TabManager = {
784
+ async open(path) {
785
+ const existingIndex = state.tabs.findIndex(t => t.path === path);
786
+ if (existingIndex >= 0) {
787
+ this.switch(existingIndex);
788
+ return;
789
+ }
790
+
791
+ const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
792
+ const data = await response.json();
793
+
794
+ if (data.error) {
795
+ alert('Error: ' + data.error);
796
+ return;
797
+ }
798
+
799
+ state.tabs.push({
800
+ path,
801
+ name: data.name,
802
+ content: data.content,
803
+ raw: data.raw,
804
+ fileType: data.fileType,
805
+ isMarp: data.isMarp || false,
806
+ css: data.css || null, // Marp CSS from marp-core
807
+ imageUrl: data.imageUrl,
808
+ pdfUrl: data.pdfUrl,
809
+ mediaUrl: data.mediaUrl,
810
+ downloadUrl: data.downloadUrl,
811
+ scrollTop: 0
812
+ });
813
+
814
+ if (state.isEditMode) {
815
+ state.isEditMode = false;
816
+ EditorManager.updateButton();
817
+ }
818
+
819
+ state.activeTabIndex = state.tabs.length - 1;
820
+ this.render();
821
+ this.renderActive();
822
+ WebSocketManager.watchFile(path);
823
+ FileTreeManager.updateHighlight();
824
+ },
825
+
826
+ switch(index) {
827
+ if (state.activeTabIndex >= 0 && state.activeTabIndex < state.tabs.length) {
828
+ if (state.isEditMode) {
829
+ const textarea = document.getElementById('editorTextarea');
830
+ if (textarea) {
831
+ state.tabs[state.activeTabIndex].raw = textarea.value;
832
+ const maxScroll = textarea.scrollHeight - textarea.clientHeight;
833
+ if (maxScroll > 0) {
834
+ const percentage = textarea.scrollTop / maxScroll;
835
+ const viewMaxScroll = elements.content.scrollHeight - elements.content.clientHeight;
836
+ state.tabs[state.activeTabIndex].scrollTop = viewMaxScroll * percentage;
837
+ }
838
+ }
839
+ } else {
840
+ state.tabs[state.activeTabIndex].scrollTop = elements.content.scrollTop;
841
+ }
842
+ }
843
+
844
+ if (state.isEditMode) {
845
+ state.isEditMode = false;
846
+ EditorManager.updateButton();
847
+ }
848
+
849
+ state.activeTabIndex = index;
850
+ this.render();
851
+ this.renderActive();
852
+ WebSocketManager.watchFile(state.tabs[index].path);
853
+ FileTreeManager.updateHighlight();
854
+ },
855
+
856
+ close(index) {
857
+ state.tabs.splice(index, 1);
858
+
859
+ if (state.tabs.length === 0) {
860
+ state.activeTabIndex = -1;
861
+ this.render();
862
+ ContentRenderer.showWelcome();
863
+ } else {
864
+ if (state.activeTabIndex >= state.tabs.length) {
865
+ state.activeTabIndex = state.tabs.length - 1;
866
+ } else if (index < state.activeTabIndex) {
867
+ state.activeTabIndex--;
868
+ }
869
+ this.render();
870
+ this.renderActive();
871
+ }
872
+ FileTreeManager.updateHighlight();
873
+ },
874
+
875
+ render() {
876
+ elements.tabBar.innerHTML = state.tabs.map((tab, i) => `
877
+ <button class="tab ${i === state.activeTabIndex ? 'active' : ''}" onclick="MDV.switchTab(${i})">
878
+ ${tab.name}
879
+ <span class="tab-close" onclick="event.stopPropagation(); MDV.closeTab(${i})">
880
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
881
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
882
+ </svg>
883
+ </span>
884
+ </button>
885
+ `).join('');
886
+ // タブがない時はタブバーを非表示
887
+ elements.tabBar.style.display = state.tabs.length === 0 ? 'none' : 'flex';
888
+ },
889
+
890
+ renderActive() {
891
+ if (state.activeTabIndex < 0 || state.activeTabIndex >= state.tabs.length) return;
892
+ const tab = state.tabs[state.activeTabIndex];
893
+
894
+ elements.content.style.padding = '';
895
+ this.renderByFileType(tab);
896
+
897
+ if (!state.skipScrollRestore) {
898
+ setTimeout(() => { elements.content.scrollTop = tab.scrollTop; }, 0);
899
+ }
900
+ },
901
+
902
+ renderByFileType(tab) {
903
+ // Clean up Marp state when switching tabs
904
+ ContentRenderer.cleanupMarp();
905
+
906
+ // Marp slides
907
+ if (tab.isMarp) {
908
+ ContentRenderer.renderMarp(tab.content, tab.css);
909
+ return;
910
+ }
911
+
912
+ switch (tab.fileType) {
913
+ case 'image':
914
+ ContentRenderer.renderImage(tab.imageUrl, tab.name);
915
+ break;
916
+ case 'pdf':
917
+ ContentRenderer.renderPDF(tab.pdfUrl, tab.name);
918
+ break;
919
+ case 'video':
920
+ ContentRenderer.renderVideo(tab.mediaUrl, tab.name);
921
+ break;
922
+ case 'audio':
923
+ ContentRenderer.renderAudio(tab.mediaUrl, tab.name);
924
+ break;
925
+ case 'archive':
926
+ case 'office':
927
+ case 'executable':
928
+ case 'binary':
929
+ ContentRenderer.renderBinary(tab.name, tab.fileType);
930
+ break;
931
+ default:
932
+ ContentRenderer.render(tab.content, tab.fileType);
933
+ }
934
+ }
935
+ };
936
+
937
+ // ============================================================
938
+ // Editor Manager
939
+ // ============================================================
940
+
941
+ const EditorManager = {
942
+ async toggle() {
943
+ if (state.activeTabIndex < 0) return;
944
+ const tab = state.tabs[state.activeTabIndex];
945
+
946
+ if (tab.fileType === 'image') {
947
+ alert('Cannot edit image files');
948
+ return;
949
+ }
950
+
951
+ state.isEditMode = !state.isEditMode;
952
+ this.updateButton();
953
+ state.isEditMode ? this.show() : await this.hide();
954
+ },
955
+
956
+ updateButton() {
957
+ if (state.isEditMode) {
958
+ elements.editToggle.classList.add('active');
959
+ elements.editLabel.textContent = 'View';
960
+ } else {
961
+ elements.editToggle.classList.remove('active');
962
+ elements.editLabel.textContent = 'Edit';
963
+ }
964
+ },
965
+
966
+ show() {
967
+ if (state.activeTabIndex < 0) return;
968
+ const tab = state.tabs[state.activeTabIndex];
969
+
970
+ const viewTopLine = this.getViewTopLine();
971
+ const viewMaxScroll = elements.content.scrollHeight - elements.content.clientHeight;
972
+ let scrollPercentage = 0;
973
+ if (viewMaxScroll > 0) {
974
+ scrollPercentage = elements.content.scrollTop / viewMaxScroll;
975
+ }
976
+
977
+ elements.content.innerHTML = `
978
+ <div class="editor-container">
979
+ <textarea class="editor-textarea" id="editorTextarea" spellcheck="false">${escapeHtml(tab.raw || '')}</textarea>
980
+ </div>
981
+ `;
982
+
983
+ elements.editorStatus.style.display = 'inline';
984
+ elements.editorStatus.textContent = 'Ready';
985
+ elements.editorStatus.className = 'editor-status';
986
+
987
+ const textarea = document.getElementById('editorTextarea');
988
+ textarea.addEventListener('input', () => {
989
+ state.hasUnsavedChanges = true;
990
+ elements.editorStatus.textContent = 'Modified';
991
+ elements.editorStatus.className = 'editor-status modified';
992
+ });
993
+
994
+ setTimeout(() => {
995
+ textarea.focus();
996
+ if (viewTopLine >= 0) {
997
+ const lineHeight = this.getTextareaLineHeight(textarea);
998
+ textarea.scrollTop = viewTopLine * lineHeight;
999
+ } else if (scrollPercentage > 0) {
1000
+ const editMaxScroll = textarea.scrollHeight - textarea.clientHeight;
1001
+ textarea.scrollTop = editMaxScroll * scrollPercentage;
1002
+ }
1003
+ }, 0);
1004
+ },
1005
+
1006
+ getViewTopLine() {
1007
+ const contentRect = elements.content.getBoundingClientRect();
1008
+ const topY = contentRect.top + 10;
1009
+ const centerX = contentRect.left + contentRect.width / 2;
1010
+
1011
+ let el = document.elementFromPoint(centerX, topY);
1012
+ if (!el || !elements.content.contains(el)) {
1013
+ return -1;
1014
+ }
1015
+
1016
+ while (el && el !== elements.content) {
1017
+ const dataLine = el.getAttribute('data-line');
1018
+ if (dataLine !== null) {
1019
+ return parseInt(dataLine, 10);
1020
+ }
1021
+ el = el.parentElement;
1022
+ }
1023
+ return -1;
1024
+ },
1025
+
1026
+ getTextareaLineHeight(textarea) {
1027
+ const lines = textarea.value.split('\n');
1028
+ if (lines.length > 0 && textarea.scrollHeight > 0) {
1029
+ return textarea.scrollHeight / lines.length;
1030
+ }
1031
+ const style = window.getComputedStyle(textarea);
1032
+ return parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.6;
1033
+ },
1034
+
1035
+ async hide() {
1036
+ if (state.activeTabIndex < 0) return;
1037
+ const tab = state.tabs[state.activeTabIndex];
1038
+
1039
+ const textarea = document.getElementById('editorTextarea');
1040
+ let topLineNumber = -1;
1041
+ let scrollPercentage = 0;
1042
+
1043
+ if (textarea) {
1044
+ tab.raw = textarea.value;
1045
+ topLineNumber = this.getEditTopLineNumber(textarea);
1046
+ const maxScroll = textarea.scrollHeight - textarea.clientHeight;
1047
+ if (maxScroll > 0) {
1048
+ scrollPercentage = textarea.scrollTop / maxScroll;
1049
+ }
1050
+ }
1051
+
1052
+ elements.editorStatus.style.display = 'none';
1053
+
1054
+ // サーバーから最新のレンダリング済みコンテンツを取得
1055
+ try {
1056
+ const response = await fetch(`/api/file?path=${encodeURIComponent(tab.path)}`);
1057
+ const data = await response.json();
1058
+ if (data.content) {
1059
+ tab.content = data.content;
1060
+ }
1061
+ if (data.raw) {
1062
+ tab.raw = data.raw;
1063
+ }
1064
+ } catch (e) {
1065
+ console.error('Failed to fetch updated content:', e);
1066
+ }
1067
+
1068
+ // WebSocket経由でファイル監視を再登録(重要)
1069
+ WebSocketManager.watchFile(tab.path);
1070
+
1071
+ state.skipScrollRestore = true;
1072
+ TabManager.renderActive();
1073
+ state.skipScrollRestore = false;
1074
+
1075
+ requestAnimationFrame(() => {
1076
+ if (topLineNumber >= 0) {
1077
+ const targetElement = this.findElementByLine(topLineNumber);
1078
+ if (targetElement) {
1079
+ const contentRect = elements.content.getBoundingClientRect();
1080
+ const targetRect = targetElement.getBoundingClientRect();
1081
+ const offsetTop = targetRect.top - contentRect.top + elements.content.scrollTop;
1082
+ elements.content.scrollTop = offsetTop - 10;
1083
+ return;
1084
+ }
1085
+ }
1086
+ if (scrollPercentage > 0) {
1087
+ const maxScroll = elements.content.scrollHeight - elements.content.clientHeight;
1088
+ elements.content.scrollTop = maxScroll * scrollPercentage;
1089
+ }
1090
+ });
1091
+ state.hasUnsavedChanges = false;
1092
+ },
1093
+
1094
+ getEditTopLineNumber(textarea) {
1095
+ const lineHeight = this.getTextareaLineHeight(textarea);
1096
+ return Math.floor(textarea.scrollTop / lineHeight);
1097
+ },
1098
+
1099
+ findElementByLine(lineNumber) {
1100
+ const markdownBody = elements.content.querySelector('.markdown-body');
1101
+ if (!markdownBody) return null;
1102
+
1103
+ const elementsWithLine = markdownBody.querySelectorAll('[data-line]');
1104
+ let bestElement = null;
1105
+ let bestLine = -1;
1106
+
1107
+ for (const el of elementsWithLine) {
1108
+ const line = parseInt(el.getAttribute('data-line'), 10);
1109
+ if (line <= lineNumber && line > bestLine) {
1110
+ bestLine = line;
1111
+ bestElement = el;
1112
+ }
1113
+ }
1114
+
1115
+ return bestElement;
1116
+ },
1117
+
1118
+ async save() {
1119
+ if (state.activeTabIndex < 0 || !state.isEditMode) return;
1120
+
1121
+ const tab = state.tabs[state.activeTabIndex];
1122
+ const textarea = document.getElementById('editorTextarea');
1123
+ if (!textarea) return;
1124
+
1125
+ const newContent = textarea.value;
1126
+
1127
+ try {
1128
+ elements.editorStatus.textContent = 'Saving...';
1129
+ elements.editorStatus.className = 'editor-status';
1130
+
1131
+ const response = await fetch('/api/file', {
1132
+ method: 'POST',
1133
+ headers: { 'Content-Type': 'application/json' },
1134
+ body: JSON.stringify({ path: tab.path, content: newContent })
1135
+ });
1136
+
1137
+ const result = await response.json();
1138
+
1139
+ if (result.error) {
1140
+ elements.editorStatus.textContent = 'Error: ' + result.error;
1141
+ elements.editorStatus.className = 'editor-status modified';
1142
+ return;
1143
+ }
1144
+
1145
+ tab.raw = newContent;
1146
+ state.hasUnsavedChanges = false;
1147
+ elements.editorStatus.textContent = 'Saved!';
1148
+ elements.editorStatus.className = 'editor-status saved';
1149
+
1150
+ setTimeout(() => {
1151
+ elements.editorStatus.textContent = 'Ready';
1152
+ elements.editorStatus.className = 'editor-status';
1153
+ }, 2000);
1154
+
1155
+ } catch (e) {
1156
+ elements.editorStatus.textContent = 'Error: ' + e.message;
1157
+ elements.editorStatus.className = 'editor-status modified';
1158
+ }
1159
+ },
1160
+
1161
+ init() {
1162
+ elements.editToggle.addEventListener('click', () => this.toggle());
1163
+ }
1164
+ };
1165
+
1166
+ // ============================================================
1167
+ // Print Manager
1168
+ // ============================================================
1169
+
1170
+ const PrintManager = {
1171
+ isMarpPresentation() {
1172
+ // Check if current content has .marpit class (Marp presentation)
1173
+ return !!elements.content.querySelector('.marpit');
1174
+ },
1175
+
1176
+ async print() {
1177
+ if (state.activeTabIndex < 0) return;
1178
+
1179
+ const tab = state.tabs[state.activeTabIndex];
1180
+
1181
+ if (this.isMarpPresentation()) {
1182
+ // Marp → use marp-cli for PDF export
1183
+ await this.exportMarpPdf(tab.path);
1184
+ } else {
1185
+ // Regular Markdown → use browser print
1186
+ this.browserPrint(tab.name);
1187
+ }
1188
+ },
1189
+
1190
+ browserPrint(fileName) {
1191
+ const pdfName = fileName.replace(/\.(md|txt)$/, '.pdf');
1192
+ const originalTitle = document.title;
1193
+
1194
+ document.title = pdfName;
1195
+ window.print();
1196
+ document.title = originalTitle;
1197
+ },
1198
+
1199
+ async exportMarpPdf(filePath) {
1200
+ const statusText = elements.statusText;
1201
+ const originalStatus = statusText.textContent;
1202
+
1203
+ try {
1204
+ statusText.textContent = 'Generating PDF...';
1205
+
1206
+ const response = await fetch('/api/pdf/export', {
1207
+ method: 'POST',
1208
+ headers: { 'Content-Type': 'application/json' },
1209
+ body: JSON.stringify({ filePath })
1210
+ });
1211
+
1212
+ if (!response.ok) {
1213
+ const error = await response.json();
1214
+ throw new Error(error.details || error.error || 'PDF export failed');
1215
+ }
1216
+
1217
+ // Download the PDF
1218
+ const blob = await response.blob();
1219
+ const url = URL.createObjectURL(blob);
1220
+ const a = document.createElement('a');
1221
+ a.href = url;
1222
+ a.download = filePath.replace(/\.md$/, '.pdf').split('/').pop();
1223
+ document.body.appendChild(a);
1224
+ a.click();
1225
+ document.body.removeChild(a);
1226
+ URL.revokeObjectURL(url);
1227
+
1228
+ statusText.textContent = 'PDF exported';
1229
+ setTimeout(() => {
1230
+ statusText.textContent = originalStatus;
1231
+ }, 2000);
1232
+ } catch (error) {
1233
+ console.error('PDF export error:', error);
1234
+ statusText.textContent = 'PDF export failed';
1235
+ setTimeout(() => {
1236
+ statusText.textContent = originalStatus;
1237
+ }, 3000);
1238
+ }
1239
+ },
1240
+
1241
+ init() {
1242
+ elements.printBtn.addEventListener('click', () => this.print());
1243
+ }
1244
+ };
1245
+
1246
+ // ============================================================
1247
+ // Shutdown Manager
1248
+ // ============================================================
1249
+
1250
+ const ShutdownManager = {
1251
+ async shutdown() {
1252
+ try {
1253
+ elements.statusText.textContent = 'Stopping...';
1254
+ await fetch('/api/shutdown', { method: 'POST' });
1255
+ } catch (e) {
1256
+ // Server stopped, connection will fail - expected
1257
+ }
1258
+ },
1259
+
1260
+ init() {
1261
+ elements.shutdownBtn.addEventListener('click', () => this.shutdown());
1262
+ }
1263
+ };
1264
+
1265
+ // ============================================================
1266
+ // Dialog Manager
1267
+ // ============================================================
1268
+
1269
+ const DialogManager = {
1270
+ currentCallback: null,
1271
+ isConfirmDialog: false,
1272
+
1273
+ show(title, options = {}) {
1274
+ elements.dialogTitle.textContent = title;
1275
+ elements.dialogInput.style.display = options.showInput ? 'block' : 'none';
1276
+ elements.dialogMessage.textContent = options.message || '';
1277
+ elements.dialogMessage.style.display = options.message ? 'block' : 'none';
1278
+
1279
+ if (options.showInput) {
1280
+ elements.dialogInput.value = options.defaultValue || '';
1281
+ }
1282
+
1283
+ elements.dialogConfirm.className = options.danger ? 'btn-danger' : 'btn-confirm';
1284
+ elements.dialogConfirm.textContent = options.confirmText || 'OK';
1285
+
1286
+ this.isConfirmDialog = options.isConfirm || false;
1287
+ this.currentCallback = options.onConfirm;
1288
+
1289
+ elements.dialogOverlay.classList.remove('hidden');
1290
+
1291
+ if (options.showInput) {
1292
+ setTimeout(() => {
1293
+ elements.dialogInput.focus();
1294
+ elements.dialogInput.select();
1295
+ }, 100);
1296
+ }
1297
+ },
1298
+
1299
+ hide() {
1300
+ elements.dialogOverlay.classList.add('hidden');
1301
+ this.currentCallback = null;
1302
+ },
1303
+
1304
+ confirm() {
1305
+ if (this.currentCallback) {
1306
+ const value = this.isConfirmDialog ? true : elements.dialogInput.value;
1307
+ this.currentCallback(value);
1308
+ }
1309
+ this.hide();
1310
+ },
1311
+
1312
+ init() {
1313
+ elements.dialogCancel.addEventListener('click', () => this.hide());
1314
+ elements.dialogConfirm.addEventListener('click', () => this.confirm());
1315
+ elements.dialogInput.addEventListener('keydown', (e) => {
1316
+ if (e.key === 'Enter') {
1317
+ e.preventDefault();
1318
+ this.confirm();
1319
+ }
1320
+ if (e.key === 'Escape') {
1321
+ this.hide();
1322
+ }
1323
+ });
1324
+ elements.dialogOverlay.addEventListener('click', (e) => {
1325
+ if (e.target === elements.dialogOverlay) {
1326
+ this.hide();
1327
+ }
1328
+ });
1329
+ }
1330
+ };
1331
+
1332
+ // ============================================================
1333
+ // File Operations Manager
1334
+ // ============================================================
1335
+
1336
+ const FileOperationsManager = {
1337
+ async createDirectory(parentPath) {
1338
+ DialogManager.show('新規フォルダ', {
1339
+ showInput: true,
1340
+ defaultValue: '新しいフォルダ',
1341
+ onConfirm: async (name) => {
1342
+ if (!name) return;
1343
+ const path = parentPath ? `${parentPath}/${name}` : name;
1344
+ try {
1345
+ await apiPost('/api/mkdir', { path });
1346
+ } catch (e) {
1347
+ alert('Error: ' + e.message);
1348
+ }
1349
+ }
1350
+ });
1351
+ },
1352
+
1353
+ async deleteItem(path, isDirectory) {
1354
+ const name = path.split('/').pop();
1355
+ const typeText = isDirectory ? 'フォルダ' : 'ファイル';
1356
+ DialogManager.show(`${typeText}を削除`, {
1357
+ message: `"${name}" を削除しますか?この操作は取り消せません。`,
1358
+ isConfirm: true,
1359
+ danger: true,
1360
+ confirmText: '削除',
1361
+ onConfirm: async () => {
1362
+ try {
1363
+ await apiRequest(`/api/file?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
1364
+ const tabIndex = state.tabs.findIndex(t => t.path === path || t.path.startsWith(path + '/'));
1365
+ if (tabIndex >= 0) {
1366
+ TabManager.close(tabIndex);
1367
+ }
1368
+ } catch (e) {
1369
+ alert('Error: ' + e.message);
1370
+ }
1371
+ }
1372
+ });
1373
+ },
1374
+
1375
+ async renameItem(path, isDirectory) {
1376
+ const oldName = path.split('/').pop();
1377
+ const parentPath = path.substring(0, path.lastIndexOf('/'));
1378
+ DialogManager.show('名前を変更', {
1379
+ showInput: true,
1380
+ defaultValue: oldName,
1381
+ onConfirm: async (newName) => {
1382
+ if (!newName || newName === oldName) return;
1383
+ const destination = parentPath ? `${parentPath}/${newName}` : newName;
1384
+ await this.executeMoveOperation(path, destination);
1385
+ }
1386
+ });
1387
+ },
1388
+
1389
+ async moveItem(source, destinationFolder) {
1390
+ const fileName = source.split('/').pop();
1391
+ const destination = destinationFolder ? `${destinationFolder}/${fileName}` : fileName;
1392
+ await this.executeMoveOperation(source, destination);
1393
+ },
1394
+
1395
+ async executeMoveOperation(source, destination) {
1396
+ try {
1397
+ const result = await apiPost('/api/move', { source, destination });
1398
+ if (result.success && updateTabPaths(source, destination)) {
1399
+ TabManager.render();
1400
+ }
1401
+ } catch (e) {
1402
+ alert('Error: ' + e.message);
1403
+ }
1404
+ },
1405
+
1406
+ async upload(targetPath, files) {
1407
+ if (!files || files.length === 0) return;
1408
+
1409
+ elements.uploadOverlay.classList.remove('hidden');
1410
+ elements.uploadProgressFill.style.width = '0%';
1411
+ elements.uploadProgressText.textContent = '0%';
1412
+
1413
+ const formData = new FormData();
1414
+ formData.append('path', targetPath || '');
1415
+ for (const file of files) {
1416
+ formData.append('files', file);
1417
+ }
1418
+
1419
+ try {
1420
+ const xhr = new XMLHttpRequest();
1421
+ xhr.open('POST', '/api/upload');
1422
+
1423
+ xhr.upload.onprogress = (e) => {
1424
+ if (e.lengthComputable) {
1425
+ const percent = Math.round((e.loaded / e.total) * 100);
1426
+ elements.uploadProgressFill.style.width = percent + '%';
1427
+ elements.uploadProgressText.textContent = percent + '%';
1428
+ }
1429
+ };
1430
+
1431
+ xhr.onload = () => {
1432
+ elements.uploadOverlay.classList.add('hidden');
1433
+ if (xhr.status !== 200) {
1434
+ try {
1435
+ const result = JSON.parse(xhr.responseText);
1436
+ alert('Error: ' + (result.detail || result.error || 'Upload failed'));
1437
+ } catch {
1438
+ alert('Error: Upload failed');
1439
+ }
1440
+ }
1441
+ };
1442
+
1443
+ xhr.onerror = () => {
1444
+ elements.uploadOverlay.classList.add('hidden');
1445
+ alert('Upload failed');
1446
+ };
1447
+
1448
+ const fileName = files.length === 1 ? files[0].name : `${files.length}ファイル`;
1449
+ elements.uploadFileName.textContent = `${fileName} をアップロード中...`;
1450
+
1451
+ xhr.send(formData);
1452
+ } catch (e) {
1453
+ elements.uploadOverlay.classList.add('hidden');
1454
+ alert('Error: ' + e.message);
1455
+ }
1456
+ },
1457
+
1458
+ download(path) {
1459
+ const a = document.createElement('a');
1460
+ a.href = `/api/download?path=${encodeURIComponent(path)}`;
1461
+ a.download = '';
1462
+ document.body.appendChild(a);
1463
+ a.click();
1464
+ document.body.removeChild(a);
1465
+ }
1466
+ };
1467
+
1468
+ // ============================================================
1469
+ // Context Menu Manager
1470
+ // ============================================================
1471
+
1472
+ const ContextMenuManager = {
1473
+ currentPath: null,
1474
+ isDirectory: false,
1475
+
1476
+ show(x, y, path, isDir) {
1477
+ this.currentPath = path;
1478
+ this.isDirectory = isDir;
1479
+
1480
+ const items = this.getMenuItems(isDir, path);
1481
+ elements.contextMenu.innerHTML = items.map(item => {
1482
+ if (item.separator) {
1483
+ return '<div class="context-menu-separator"></div>';
1484
+ }
1485
+ return `<div class="context-menu-item ${item.danger ? 'danger' : ''}" data-action="${item.action}">${item.label}</div>`;
1486
+ }).join('');
1487
+
1488
+ const menuRect = elements.contextMenu.getBoundingClientRect();
1489
+ const maxX = window.innerWidth - 170;
1490
+ const maxY = window.innerHeight - (items.length * 36);
1491
+ elements.contextMenu.style.left = Math.min(x, maxX) + 'px';
1492
+ elements.contextMenu.style.top = Math.min(y, maxY) + 'px';
1493
+
1494
+ elements.contextMenu.classList.remove('hidden');
1495
+ },
1496
+
1497
+ hide() {
1498
+ elements.contextMenu.classList.add('hidden');
1499
+ this.currentPath = null;
1500
+ },
1501
+
1502
+ getMenuItems(isDir, path) {
1503
+ const pathDisplay = state.rootPath ? `${state.rootPath}/${path}` : path;
1504
+ if (isDir) {
1505
+ return [
1506
+ { label: '新規フォルダ', action: 'newFolder' },
1507
+ { label: 'アップロード', action: 'upload' },
1508
+ { separator: true },
1509
+ { label: '名前を変更', action: 'rename' },
1510
+ { label: 'パスをコピー', action: 'copyPath' },
1511
+ { separator: true },
1512
+ { label: '削除', action: 'delete', danger: true }
1513
+ ];
1514
+ } else {
1515
+ return [
1516
+ { label: '開く', action: 'open' },
1517
+ { label: 'ダウンロード', action: 'download' },
1518
+ { separator: true },
1519
+ { label: '名前を変更', action: 'rename' },
1520
+ { label: 'パスをコピー', action: 'copyPath' },
1521
+ { separator: true },
1522
+ { label: '削除', action: 'delete', danger: true }
1523
+ ];
1524
+ }
1525
+ },
1526
+
1527
+ handleAction(action) {
1528
+ const path = this.currentPath;
1529
+ const isDir = this.isDirectory;
1530
+ this.hide();
1531
+
1532
+ switch (action) {
1533
+ case 'open':
1534
+ TabManager.open(path);
1535
+ break;
1536
+ case 'download':
1537
+ FileOperationsManager.download(path);
1538
+ break;
1539
+ case 'rename':
1540
+ FileOperationsManager.renameItem(path, isDir);
1541
+ break;
1542
+ case 'delete':
1543
+ FileOperationsManager.deleteItem(path, isDir);
1544
+ break;
1545
+ case 'newFolder':
1546
+ FileOperationsManager.createDirectory(path);
1547
+ break;
1548
+ case 'upload':
1549
+ state.uploadTargetPath = path;
1550
+ elements.fileInput.click();
1551
+ break;
1552
+ case 'copyPath':
1553
+ const fullPath = state.rootPath ? `${state.rootPath}/${path}` : path;
1554
+ navigator.clipboard.writeText(fullPath).then(() => {
1555
+ console.log('パスをコピーしました:', fullPath);
1556
+ }).catch(err => {
1557
+ console.error('コピーに失敗:', err);
1558
+ alert('パスのコピーに失敗しました');
1559
+ });
1560
+ break;
1561
+ }
1562
+ },
1563
+
1564
+ init() {
1565
+ elements.contextMenu.addEventListener('click', (e) => {
1566
+ const item = e.target.closest('.context-menu-item');
1567
+ if (item) {
1568
+ this.handleAction(item.dataset.action);
1569
+ }
1570
+ });
1571
+
1572
+ document.addEventListener('click', (e) => {
1573
+ if (!elements.contextMenu.contains(e.target)) {
1574
+ this.hide();
1575
+ }
1576
+ });
1577
+
1578
+ document.addEventListener('contextmenu', (e) => {
1579
+ const treeItem = e.target.closest('.tree-item');
1580
+ if (treeItem && elements.fileTree.contains(treeItem)) {
1581
+ e.preventDefault();
1582
+ const path = treeItem.dataset.path;
1583
+ const isDir = !!treeItem.querySelector('.tree-children');
1584
+ this.show(e.clientX, e.clientY, path, isDir);
1585
+ }
1586
+ });
1587
+
1588
+ elements.fileTree.addEventListener('contextmenu', (e) => {
1589
+ if (e.target === elements.fileTree) {
1590
+ e.preventDefault();
1591
+ this.show(e.clientX, e.clientY, '', true);
1592
+ }
1593
+ });
1594
+
1595
+ elements.fileInput.addEventListener('change', (e) => {
1596
+ if (e.target.files.length > 0) {
1597
+ FileOperationsManager.upload(state.uploadTargetPath || '', e.target.files);
1598
+ e.target.value = '';
1599
+ }
1600
+ });
1601
+ }
1602
+ };
1603
+
1604
+ // ============================================================
1605
+ // Drag & Drop Manager
1606
+ // ============================================================
1607
+
1608
+ const DragDropManager = {
1609
+ draggedPath: null,
1610
+
1611
+ clearDragOverStyles() {
1612
+ document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
1613
+ elements.fileTree.classList.remove('drag-over');
1614
+ },
1615
+
1616
+ init() {
1617
+ elements.fileTree.addEventListener('dragstart', (e) => {
1618
+ const treeItem = e.target.closest('.tree-item');
1619
+ if (treeItem) {
1620
+ this.draggedPath = treeItem.dataset.path;
1621
+ e.dataTransfer.effectAllowed = 'move';
1622
+ e.dataTransfer.setData('text/plain', this.draggedPath);
1623
+ treeItem.style.opacity = '0.5';
1624
+ }
1625
+ });
1626
+
1627
+ elements.fileTree.addEventListener('dragend', (e) => {
1628
+ const treeItem = e.target.closest('.tree-item');
1629
+ if (treeItem) {
1630
+ treeItem.style.opacity = '';
1631
+ }
1632
+ this.draggedPath = null;
1633
+ this.clearDragOverStyles();
1634
+ });
1635
+
1636
+ elements.fileTree.addEventListener('dragover', (e) => {
1637
+ e.preventDefault();
1638
+
1639
+ // Root area drop (external files or internal move to root)
1640
+ if (e.target === elements.fileTree) {
1641
+ if (e.dataTransfer.types.includes('Files') || this.draggedPath) {
1642
+ elements.fileTree.classList.add('drag-over');
1643
+ }
1644
+ return;
1645
+ }
1646
+
1647
+ // Directory drop
1648
+ const treeItem = e.target.closest('.tree-item');
1649
+ if (treeItem && treeItem.querySelector('.tree-children')) {
1650
+ e.dataTransfer.dropEffect = 'move';
1651
+ treeItem.querySelector('.tree-item-content').classList.add('drag-over');
1652
+ }
1653
+ });
1654
+
1655
+ elements.fileTree.addEventListener('dragleave', (e) => {
1656
+ if (e.target === elements.fileTree) {
1657
+ elements.fileTree.classList.remove('drag-over');
1658
+ return;
1659
+ }
1660
+
1661
+ const treeItem = e.target.closest('.tree-item');
1662
+ if (treeItem) {
1663
+ treeItem.querySelector('.tree-item-content')?.classList.remove('drag-over');
1664
+ }
1665
+ });
1666
+
1667
+ elements.fileTree.addEventListener('drop', (e) => {
1668
+ e.preventDefault();
1669
+ this.clearDragOverStyles();
1670
+
1671
+ // Root area drop
1672
+ if (e.target === elements.fileTree) {
1673
+ // Internal file move to root
1674
+ if (this.draggedPath) {
1675
+ // Already at root? (no '/' in path means it's at root)
1676
+ if (!this.draggedPath.includes('/')) {
1677
+ return;
1678
+ }
1679
+ FileOperationsManager.moveItem(this.draggedPath, '');
1680
+ return;
1681
+ }
1682
+ // External file upload to root
1683
+ if (e.dataTransfer.files.length > 0) {
1684
+ FileOperationsManager.upload('', e.dataTransfer.files);
1685
+ }
1686
+ return;
1687
+ }
1688
+
1689
+ // Directory drop
1690
+ const treeItem = e.target.closest('.tree-item');
1691
+ if (!treeItem || !treeItem.querySelector('.tree-children')) return;
1692
+
1693
+ const targetPath = treeItem.dataset.path;
1694
+
1695
+ if (this.draggedPath && this.draggedPath !== targetPath) {
1696
+ if (targetPath.startsWith(this.draggedPath + '/')) {
1697
+ alert('フォルダを自身のサブフォルダに移動することはできません');
1698
+ return;
1699
+ }
1700
+ FileOperationsManager.moveItem(this.draggedPath, targetPath);
1701
+ } else if (e.dataTransfer.files.length > 0) {
1702
+ FileOperationsManager.upload(targetPath, e.dataTransfer.files);
1703
+ }
1704
+ });
1705
+ }
1706
+ };
1707
+
1708
+ // ============================================================
1709
+ // Keyboard Shortcuts
1710
+ // ============================================================
1711
+
1712
+ const KeyboardManager = {
1713
+ selectedTreePath: null,
1714
+
1715
+ shortcuts: {
1716
+ 'b': { handler: () => SidebarManager.toggle() },
1717
+ 'w': { handler: () => TabManager.close(state.activeTabIndex), requiresTab: true },
1718
+ 'e': { handler: () => EditorManager.toggle(), requiresTab: true },
1719
+ 's': { handler: () => EditorManager.save(), requiresEditMode: true },
1720
+ 'p': { handler: () => PrintManager.print(), requiresTab: true }
1721
+ },
1722
+
1723
+ handleModShortcut(key) {
1724
+ const shortcut = this.shortcuts[key];
1725
+ if (!shortcut) return false;
1726
+ if (shortcut.requiresTab && state.activeTabIndex < 0) return false;
1727
+ if (shortcut.requiresEditMode && !state.isEditMode) return false;
1728
+ shortcut.handler();
1729
+ return true;
1730
+ },
1731
+
1732
+ handleTreeItemShortcut(key) {
1733
+ if (!this.selectedTreePath) return false;
1734
+
1735
+ const isTextInput = document.activeElement.tagName === 'INPUT' ||
1736
+ document.activeElement.tagName === 'TEXTAREA';
1737
+
1738
+ const activeItem = document.querySelector(`.tree-item[data-path="${this.selectedTreePath}"]`);
1739
+ const isDir = activeItem && !!activeItem.querySelector('.tree-children');
1740
+
1741
+ if ((key === 'Delete' || key === 'Backspace') && !isTextInput) {
1742
+ FileOperationsManager.deleteItem(this.selectedTreePath, isDir);
1743
+ return true;
1744
+ }
1745
+ if (key === 'F2') {
1746
+ FileOperationsManager.renameItem(this.selectedTreePath, isDir);
1747
+ return true;
1748
+ }
1749
+ return false;
1750
+ },
1751
+
1752
+ init() {
1753
+ document.addEventListener('keydown', (e) => {
1754
+ const isMod = e.metaKey || e.ctrlKey;
1755
+
1756
+ if (isMod && this.handleModShortcut(e.key)) {
1757
+ e.preventDefault();
1758
+ return;
1759
+ }
1760
+
1761
+ if (this.handleTreeItemShortcut(e.key)) {
1762
+ e.preventDefault();
1763
+ }
1764
+ });
1765
+
1766
+ elements.fileTree.addEventListener('click', (e) => {
1767
+ const treeItem = e.target.closest('.tree-item');
1768
+ if (treeItem) {
1769
+ this.selectedTreePath = treeItem.dataset.path;
1770
+ }
1771
+ });
1772
+ }
1773
+ };
1774
+
1775
+ // ============================================================
1776
+ // Public API (Global Functions for onclick handlers)
1777
+ // ============================================================
1778
+
1779
+ window.MDV = {
1780
+ openFile: (path) => TabManager.open(path),
1781
+ switchTab: (index) => TabManager.switch(index),
1782
+ closeTab: (index) => TabManager.close(index),
1783
+ toggleDirectory: async (element) => {
1784
+ const chevron = element.querySelector('.chevron');
1785
+ const children = element.nextElementSibling;
1786
+ const treeItem = element.closest('.tree-item');
1787
+ const path = treeItem.dataset.path;
1788
+ const isLoaded = treeItem.dataset.loaded === 'true';
1789
+ const isExpanding = children.classList.contains('collapsed');
1790
+
1791
+ // 展開時に未読み込みならAPIで取得
1792
+ if (isExpanding && !isLoaded) {
1793
+ chevron.classList.add('loading');
1794
+ await FileTreeManager.expandDirectory(path, children);
1795
+ chevron.classList.remove('loading');
1796
+ }
1797
+
1798
+ chevron.classList.toggle('expanded');
1799
+ children.classList.toggle('collapsed');
1800
+ }
1801
+ };
1802
+
1803
+ // ============================================================
1804
+ // Initialize Application
1805
+ // ============================================================
1806
+
1807
+ const MEDIA_FILE_TYPES = ['image', 'pdf', 'video', 'audio', 'archive', 'office', 'executable', 'binary'];
1808
+
1809
+ async function refreshCurrentTab() {
1810
+ if (state.activeTabIndex < 0 || state.isEditMode) return;
1811
+ const tab = state.tabs[state.activeTabIndex];
1812
+ if (!tab || MEDIA_FILE_TYPES.includes(tab.fileType)) return;
1813
+
1814
+ WebSocketManager.watchFile(tab.path);
1815
+
1816
+ try {
1817
+ const response = await fetch(`/api/file?path=${encodeURIComponent(tab.path)}`);
1818
+ const data = await response.json();
1819
+ if (data.content && data.content !== tab.content) {
1820
+ tab.content = data.content;
1821
+ if (data.raw) {
1822
+ tab.raw = data.raw;
1823
+ }
1824
+ const currentScroll = saveScrollPosition(elements.content);
1825
+ ContentRenderer.render(data.content, data.fileType || tab.fileType);
1826
+ restoreScrollPosition(elements.content, currentScroll);
1827
+ }
1828
+ } catch (e) {
1829
+ console.error('Failed to refresh tab:', e);
1830
+ }
1831
+ }
1832
+
1833
+ async function init() {
1834
+ ThemeManager.init();
1835
+ SidebarManager.init();
1836
+ ResizeHandler.init();
1837
+ EditorManager.init();
1838
+ PrintManager.init();
1839
+ ShutdownManager.init();
1840
+ DialogManager.init();
1841
+ ContextMenuManager.init();
1842
+ DragDropManager.init();
1843
+ KeyboardManager.init();
1844
+ TabManager.render(); // 初期状態でタブバーを非表示
1845
+
1846
+ try {
1847
+ const infoResponse = await fetch('/api/info');
1848
+ const info = await infoResponse.json();
1849
+ state.rootPath = info.rootPath;
1850
+ } catch (e) {
1851
+ console.error('Failed to fetch server info:', e);
1852
+ }
1853
+
1854
+ await FileTreeManager.load();
1855
+ WebSocketManager.connect();
1856
+
1857
+ // ブラウザタブがアクティブになった時に最新データを取得
1858
+ document.addEventListener('visibilitychange', () => {
1859
+ if (document.visibilityState === 'visible') {
1860
+ refreshCurrentTab();
1861
+ }
1862
+ });
1863
+
1864
+ // ウィンドウがフォーカスされた時も取得
1865
+ window.addEventListener('focus', () => {
1866
+ refreshCurrentTab();
1867
+ });
1868
+
1869
+ const params = new URLSearchParams(window.location.search);
1870
+ const initialFile = params.get('file');
1871
+ if (initialFile) {
1872
+ TabManager.open(initialFile);
1873
+ }
1874
+ }
1875
+
1876
+ // DOMContentLoadedを待ってから初期化
1877
+ if (document.readyState === 'loading') {
1878
+ document.addEventListener('DOMContentLoaded', init);
1879
+ } else {
1880
+ init();
1881
+ }
1882
+
1883
+ })();