mdv-live 0.1.15__py3-none-any.whl

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.

Potentially problematic release.


This version of mdv-live might be problematic. Click here for more details.

mdv/static/app.js ADDED
@@ -0,0 +1,1465 @@
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
+ 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>'
67
+ };
68
+
69
+ // ============================================================
70
+ // State
71
+ // ============================================================
72
+
73
+ const state = {
74
+ theme: localStorage.getItem(STORAGE_KEYS.THEME) || 'light',
75
+ sidebarWidth: parseInt(localStorage.getItem(STORAGE_KEYS.SIDEBAR_WIDTH)) || 280,
76
+ tabs: [],
77
+ activeTabIndex: -1,
78
+ ws: null,
79
+ isEditMode: false,
80
+ hasUnsavedChanges: false,
81
+ isResizing: false,
82
+ skipScrollRestore: false,
83
+ uploadTargetPath: '',
84
+ rootPath: ''
85
+ };
86
+
87
+ // ============================================================
88
+ // DOM Elements
89
+ // ============================================================
90
+
91
+ const elements = {
92
+ sidebar: document.getElementById('sidebar'),
93
+ sidebarToggle: document.getElementById('sidebarToggle'),
94
+ themeToggle: document.getElementById('themeToggle'),
95
+ printBtn: document.getElementById('printBtn'),
96
+ sunIcon: document.getElementById('sunIcon'),
97
+ moonIcon: document.getElementById('moonIcon'),
98
+ hljsTheme: document.getElementById('hljs-theme'),
99
+ fileTree: document.getElementById('fileTree'),
100
+ tabBar: document.getElementById('tabBar'),
101
+ content: document.getElementById('content'),
102
+ statusDot: document.getElementById('statusDot'),
103
+ statusText: document.getElementById('statusText'),
104
+ resizeHandle: document.getElementById('resizeHandle'),
105
+ editToggle: document.getElementById('editToggle'),
106
+ editLabel: document.getElementById('editLabel'),
107
+ editorStatus: document.getElementById('editorStatus'),
108
+ // File browser elements
109
+ contextMenu: document.getElementById('contextMenu'),
110
+ dialogOverlay: document.getElementById('dialogOverlay'),
111
+ dialogTitle: document.getElementById('dialogTitle'),
112
+ dialogInput: document.getElementById('dialogInput'),
113
+ dialogMessage: document.getElementById('dialogMessage'),
114
+ dialogCancel: document.getElementById('dialogCancel'),
115
+ dialogConfirm: document.getElementById('dialogConfirm'),
116
+ uploadOverlay: document.getElementById('uploadOverlay'),
117
+ uploadFileName: document.getElementById('uploadFileName'),
118
+ uploadProgressFill: document.getElementById('uploadProgressFill'),
119
+ uploadProgressText: document.getElementById('uploadProgressText'),
120
+ fileInput: document.getElementById('fileInput')
121
+ };
122
+
123
+ // ============================================================
124
+ // Utilities
125
+ // ============================================================
126
+
127
+ function escapeHtml(text) {
128
+ const div = document.createElement('div');
129
+ div.textContent = text;
130
+ return div.innerHTML;
131
+ }
132
+
133
+ function getFileIcon(iconName) {
134
+ return FILE_ICONS[iconName] || FILE_ICONS.default;
135
+ }
136
+
137
+ // ============================================================
138
+ // Theme Management
139
+ // ============================================================
140
+
141
+ const ThemeManager = {
142
+ set(theme) {
143
+ state.theme = theme;
144
+ document.body.dataset.theme = theme;
145
+ localStorage.setItem(STORAGE_KEYS.THEME, theme);
146
+
147
+ const isLight = theme === 'light';
148
+ elements.sunIcon.style.display = isLight ? 'none' : 'block';
149
+ elements.moonIcon.style.display = isLight ? 'block' : 'none';
150
+ elements.hljsTheme.href = HLJS_THEMES[theme];
151
+
152
+ const mermaidConfig = MERMAID_THEMES[theme];
153
+ mermaid.initialize({
154
+ startOnLoad: false,
155
+ theme: mermaidConfig.theme,
156
+ themeVariables: mermaidConfig.variables
157
+ });
158
+ },
159
+
160
+ toggle() {
161
+ this.set(state.theme === 'dark' ? 'light' : 'dark');
162
+ if (state.activeTabIndex >= 0) {
163
+ const currentScroll = elements.content.scrollTop;
164
+ TabManager.renderActive();
165
+ requestAnimationFrame(() => {
166
+ elements.content.scrollTop = currentScroll;
167
+ });
168
+ }
169
+ },
170
+
171
+ init() {
172
+ this.set(state.theme);
173
+ elements.themeToggle.addEventListener('click', () => this.toggle());
174
+ }
175
+ };
176
+
177
+ // ============================================================
178
+ // Sidebar Management
179
+ // ============================================================
180
+
181
+ const SidebarManager = {
182
+ toggle() {
183
+ elements.sidebar.classList.toggle('collapsed');
184
+ if (!elements.sidebar.classList.contains('collapsed')) {
185
+ elements.sidebar.style.width = state.sidebarWidth + 'px';
186
+ }
187
+ },
188
+
189
+ setWidth(width) {
190
+ if (width < 50) {
191
+ elements.sidebar.classList.add('collapsed');
192
+ } else {
193
+ elements.sidebar.classList.remove('collapsed');
194
+ elements.sidebar.style.width = width + 'px';
195
+ state.sidebarWidth = width;
196
+ localStorage.setItem(STORAGE_KEYS.SIDEBAR_WIDTH, width);
197
+ }
198
+ },
199
+
200
+ init() {
201
+ elements.sidebar.style.width = state.sidebarWidth + 'px';
202
+ elements.sidebarToggle.addEventListener('click', () => this.toggle());
203
+ }
204
+ };
205
+
206
+ // ============================================================
207
+ // Resize Handler
208
+ // ============================================================
209
+
210
+ const ResizeHandler = {
211
+ start() {
212
+ state.isResizing = true;
213
+ elements.resizeHandle.classList.add('active');
214
+ document.body.style.cursor = 'col-resize';
215
+ document.body.style.userSelect = 'none';
216
+ },
217
+
218
+ move(clientX) {
219
+ if (!state.isResizing) return;
220
+ if (clientX >= 0 && clientX <= 500) {
221
+ SidebarManager.setWidth(clientX);
222
+ }
223
+ },
224
+
225
+ end() {
226
+ if (state.isResizing) {
227
+ state.isResizing = false;
228
+ elements.resizeHandle.classList.remove('active');
229
+ document.body.style.cursor = '';
230
+ document.body.style.userSelect = '';
231
+ }
232
+ },
233
+
234
+ init() {
235
+ elements.resizeHandle.addEventListener('mousedown', () => this.start());
236
+ document.addEventListener('mousemove', (e) => this.move(e.clientX));
237
+ document.addEventListener('mouseup', () => this.end());
238
+ }
239
+ };
240
+
241
+ // ============================================================
242
+ // WebSocket Manager
243
+ // ============================================================
244
+
245
+ const WebSocketManager = {
246
+ connect() {
247
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
248
+ state.ws = new WebSocket(`${protocol}//${location.host}/ws`);
249
+
250
+ state.ws.onopen = () => {
251
+ elements.statusDot.classList.remove('disconnected');
252
+ elements.statusText.textContent = 'Connected';
253
+ if (state.activeTabIndex >= 0) {
254
+ this.watchFile(state.tabs[state.activeTabIndex].path);
255
+ }
256
+ };
257
+
258
+ state.ws.onmessage = (event) => {
259
+ const data = JSON.parse(event.data);
260
+ if (data.type === 'file_update' && state.activeTabIndex >= 0) {
261
+ const tab = state.tabs[state.activeTabIndex];
262
+ if (data.fileType === 'image' && data.reload) {
263
+ ContentRenderer.renderImage(tab.imageUrl, tab.name);
264
+ } else if (data.content) {
265
+ tab.content = data.content;
266
+ if (data.raw) {
267
+ tab.raw = data.raw;
268
+ }
269
+ if (state.isEditMode) {
270
+ // editモード中でも未保存変更がなければ更新
271
+ if (!state.hasUnsavedChanges && data.raw) {
272
+ const textarea = document.getElementById('editorTextarea');
273
+ if (textarea) {
274
+ const currentScroll = textarea.scrollTop;
275
+ textarea.value = data.raw;
276
+ requestAnimationFrame(() => {
277
+ textarea.scrollTop = currentScroll;
278
+ });
279
+ }
280
+ }
281
+ } else {
282
+ const currentScroll = elements.content.scrollTop;
283
+ ContentRenderer.render(data.content, data.fileType || tab.fileType);
284
+ requestAnimationFrame(() => {
285
+ elements.content.scrollTop = currentScroll;
286
+ });
287
+ }
288
+ }
289
+ } else if (data.type === 'tree_update' && data.tree) {
290
+ FileTreeManager.update(data.tree);
291
+ }
292
+ };
293
+
294
+ state.ws.onclose = () => {
295
+ elements.statusDot.classList.add('disconnected');
296
+ elements.statusText.textContent = 'Disconnected';
297
+ setTimeout(() => this.connect(), 3000);
298
+ };
299
+
300
+ state.ws.onerror = () => state.ws.close();
301
+ },
302
+
303
+ watchFile(path) {
304
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
305
+ state.ws.send(JSON.stringify({ type: 'watch', path }));
306
+ }
307
+ }
308
+ };
309
+
310
+ // ============================================================
311
+ // File Tree Manager
312
+ // ============================================================
313
+
314
+ const FileTreeManager = {
315
+ async load() {
316
+ const response = await fetch('/api/tree');
317
+ const tree = await response.json();
318
+ elements.fileTree.innerHTML = this.renderItems(tree);
319
+ },
320
+
321
+ update(tree) {
322
+ const expandedPaths = new Set();
323
+ document.querySelectorAll('.tree-item').forEach(item => {
324
+ const children = item.querySelector('.tree-children');
325
+ if (children && !children.classList.contains('collapsed')) {
326
+ expandedPaths.add(item.dataset.path);
327
+ }
328
+ });
329
+
330
+ elements.fileTree.innerHTML = this.renderItems(tree);
331
+
332
+ expandedPaths.forEach(path => {
333
+ const item = document.querySelector(`.tree-item[data-path="${path}"]`);
334
+ if (item) {
335
+ const children = item.querySelector('.tree-children');
336
+ const chevron = item.querySelector('.chevron');
337
+ if (children) children.classList.remove('collapsed');
338
+ if (chevron) chevron.classList.add('expanded');
339
+ }
340
+ });
341
+
342
+ this.updateHighlight();
343
+ },
344
+
345
+ renderItems(items) {
346
+ if (!items || items.length === 0) return '';
347
+
348
+ return items.map(item => {
349
+ if (item.type === 'directory') {
350
+ return this.renderDirectory(item);
351
+ }
352
+ return this.renderFile(item);
353
+ }).join('');
354
+ },
355
+
356
+ renderDirectory(item) {
357
+ return `
358
+ <div class="tree-item" data-path="${item.path}" draggable="true">
359
+ <div class="tree-item-content" onclick="MDV.toggleDirectory(this)">
360
+ <svg class="chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
361
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
362
+ </svg>
363
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
364
+ <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" />
365
+ </svg>
366
+ <span class="name">${item.name}</span>
367
+ </div>
368
+ <div class="tree-children collapsed">${this.renderItems(item.children)}</div>
369
+ </div>
370
+ `;
371
+ },
372
+
373
+ renderFile(item) {
374
+ const iconClass = item.icon ? `icon-${item.icon}` : '';
375
+ const iconSvg = getFileIcon(item.icon);
376
+ return `
377
+ <div class="tree-item" data-path="${item.path}" draggable="true">
378
+ <div class="tree-item-content" onclick="MDV.openFile('${item.path}')">
379
+ <span class="${iconClass}" style="margin-left: 22px; display: flex; align-items: center;">
380
+ ${iconSvg}
381
+ </span>
382
+ <span class="name">${item.name}</span>
383
+ </div>
384
+ </div>
385
+ `;
386
+ },
387
+
388
+ updateHighlight() {
389
+ document.querySelectorAll('.tree-item-content.active').forEach(el => {
390
+ el.classList.remove('active');
391
+ });
392
+ if (state.activeTabIndex >= 0) {
393
+ const path = state.tabs[state.activeTabIndex].path;
394
+ const el = document.querySelector(`.tree-item[data-path="${path}"] > .tree-item-content`);
395
+ if (el) el.classList.add('active');
396
+ }
397
+ }
398
+ };
399
+
400
+ // ============================================================
401
+ // Content Renderer
402
+ // ============================================================
403
+
404
+ const ContentRenderer = {
405
+ render(htmlContent, fileType) {
406
+ elements.content.innerHTML = `<div class="markdown-body">${htmlContent}</div>`;
407
+
408
+ elements.content.querySelectorAll('pre code').forEach(block => {
409
+ hljs.highlightElement(block);
410
+ });
411
+
412
+ if (fileType === 'markdown') {
413
+ this.renderMermaid();
414
+ }
415
+ },
416
+
417
+ async renderMermaid() {
418
+ const blocks = elements.content.querySelectorAll('code.language-mermaid');
419
+ for (let i = 0; i < blocks.length; i++) {
420
+ const block = blocks[i];
421
+ const pre = block.parentElement;
422
+ const mermaidCode = block.textContent;
423
+ const div = document.createElement('div');
424
+ div.className = 'mermaid';
425
+
426
+ try {
427
+ const { svg } = await mermaid.render(`mermaid-${Date.now()}-${i}`, mermaidCode);
428
+ div.innerHTML = svg;
429
+ pre.replaceWith(div);
430
+ } catch (e) {
431
+ console.error('Mermaid error:', e);
432
+ }
433
+ }
434
+ },
435
+
436
+ renderImage(imageUrl, name) {
437
+ const url = imageUrl + '&t=' + Date.now();
438
+ elements.content.innerHTML = `
439
+ <div class="image-preview">
440
+ <img src="${url}" alt="${name}" />
441
+ <div class="image-info">${name}</div>
442
+ </div>
443
+ `;
444
+ },
445
+
446
+ renderPDF(pdfUrl, name) {
447
+ const url = pdfUrl + '&t=' + Date.now();
448
+ elements.content.style.padding = '0';
449
+ elements.content.innerHTML = `
450
+ <div class="pdf-viewer">
451
+ <iframe src="${url}" title="${name}"></iframe>
452
+ </div>
453
+ `;
454
+ },
455
+
456
+ renderVideo(mediaUrl, name) {
457
+ elements.content.innerHTML = `
458
+ <div class="video-preview">
459
+ <video controls>
460
+ <source src="${mediaUrl}" type="video/mp4">
461
+ お使いのブラウザは動画再生に対応していません。
462
+ </video>
463
+ <div class="media-info">${name}</div>
464
+ </div>
465
+ `;
466
+ },
467
+
468
+ renderAudio(mediaUrl, name) {
469
+ elements.content.innerHTML = `
470
+ <div class="audio-preview">
471
+ <audio controls>
472
+ <source src="${mediaUrl}">
473
+ お使いのブラウザは音声再生に対応していません。
474
+ </audio>
475
+ <div class="media-info">${name}</div>
476
+ </div>
477
+ `;
478
+ },
479
+
480
+ showWelcome() {
481
+ elements.content.innerHTML = `
482
+ <div class="welcome">
483
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
484
+ <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" />
485
+ </svg>
486
+ <h2>Select a file</h2>
487
+ <p>Choose a file from the sidebar</p>
488
+ <p><kbd>Cmd+E</kbd> Edit &nbsp; <kbd>Cmd+S</kbd> Save &nbsp; <kbd>Cmd+P</kbd> PDF</p>
489
+ </div>
490
+ `;
491
+ }
492
+ };
493
+
494
+ // ============================================================
495
+ // Tab Manager
496
+ // ============================================================
497
+
498
+ const TabManager = {
499
+ async open(path) {
500
+ const existingIndex = state.tabs.findIndex(t => t.path === path);
501
+ if (existingIndex >= 0) {
502
+ this.switch(existingIndex);
503
+ return;
504
+ }
505
+
506
+ const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
507
+ const data = await response.json();
508
+
509
+ if (data.error) {
510
+ alert('Error: ' + data.error);
511
+ return;
512
+ }
513
+
514
+ state.tabs.push({
515
+ path,
516
+ name: data.name,
517
+ content: data.content,
518
+ raw: data.raw,
519
+ fileType: data.fileType,
520
+ imageUrl: data.imageUrl,
521
+ pdfUrl: data.pdfUrl,
522
+ mediaUrl: data.mediaUrl,
523
+ scrollTop: 0
524
+ });
525
+
526
+ if (state.isEditMode) {
527
+ state.isEditMode = false;
528
+ EditorManager.updateButton();
529
+ }
530
+
531
+ state.activeTabIndex = state.tabs.length - 1;
532
+ this.render();
533
+ this.renderActive();
534
+ WebSocketManager.watchFile(path);
535
+ FileTreeManager.updateHighlight();
536
+ },
537
+
538
+ switch(index) {
539
+ if (state.activeTabIndex >= 0 && state.activeTabIndex < state.tabs.length) {
540
+ if (state.isEditMode) {
541
+ const textarea = document.getElementById('editorTextarea');
542
+ if (textarea) {
543
+ state.tabs[state.activeTabIndex].raw = textarea.value;
544
+ const maxScroll = textarea.scrollHeight - textarea.clientHeight;
545
+ if (maxScroll > 0) {
546
+ const percentage = textarea.scrollTop / maxScroll;
547
+ const viewMaxScroll = elements.content.scrollHeight - elements.content.clientHeight;
548
+ state.tabs[state.activeTabIndex].scrollTop = viewMaxScroll * percentage;
549
+ }
550
+ }
551
+ } else {
552
+ state.tabs[state.activeTabIndex].scrollTop = elements.content.scrollTop;
553
+ }
554
+ }
555
+
556
+ if (state.isEditMode) {
557
+ state.isEditMode = false;
558
+ EditorManager.updateButton();
559
+ }
560
+
561
+ state.activeTabIndex = index;
562
+ this.render();
563
+ this.renderActive();
564
+ WebSocketManager.watchFile(state.tabs[index].path);
565
+ FileTreeManager.updateHighlight();
566
+ },
567
+
568
+ close(index) {
569
+ state.tabs.splice(index, 1);
570
+
571
+ if (state.tabs.length === 0) {
572
+ state.activeTabIndex = -1;
573
+ this.render();
574
+ ContentRenderer.showWelcome();
575
+ } else {
576
+ if (state.activeTabIndex >= state.tabs.length) {
577
+ state.activeTabIndex = state.tabs.length - 1;
578
+ } else if (index < state.activeTabIndex) {
579
+ state.activeTabIndex--;
580
+ }
581
+ this.render();
582
+ this.renderActive();
583
+ }
584
+ FileTreeManager.updateHighlight();
585
+ },
586
+
587
+ render() {
588
+ elements.tabBar.innerHTML = state.tabs.map((tab, i) => `
589
+ <button class="tab ${i === state.activeTabIndex ? 'active' : ''}" onclick="MDV.switchTab(${i})">
590
+ ${tab.name}
591
+ <span class="tab-close" onclick="event.stopPropagation(); MDV.closeTab(${i})">
592
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
593
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
594
+ </svg>
595
+ </span>
596
+ </button>
597
+ `).join('');
598
+ // タブがない時はタブバーを非表示
599
+ elements.tabBar.style.display = state.tabs.length === 0 ? 'none' : 'flex';
600
+ },
601
+
602
+ renderActive() {
603
+ if (state.activeTabIndex < 0 || state.activeTabIndex >= state.tabs.length) return;
604
+ const tab = state.tabs[state.activeTabIndex];
605
+
606
+ elements.content.style.padding = '';
607
+
608
+ if (tab.fileType === 'image') {
609
+ ContentRenderer.renderImage(tab.imageUrl, tab.name);
610
+ } else if (tab.fileType === 'pdf') {
611
+ ContentRenderer.renderPDF(tab.pdfUrl, tab.name);
612
+ } else if (tab.fileType === 'video') {
613
+ ContentRenderer.renderVideo(tab.mediaUrl, tab.name);
614
+ } else if (tab.fileType === 'audio') {
615
+ ContentRenderer.renderAudio(tab.mediaUrl, tab.name);
616
+ } else {
617
+ ContentRenderer.render(tab.content, tab.fileType);
618
+ }
619
+
620
+ if (!state.skipScrollRestore) {
621
+ setTimeout(() => { elements.content.scrollTop = tab.scrollTop; }, 0);
622
+ }
623
+ }
624
+ };
625
+
626
+ // ============================================================
627
+ // Editor Manager
628
+ // ============================================================
629
+
630
+ const EditorManager = {
631
+ toggle() {
632
+ if (state.activeTabIndex < 0) return;
633
+ const tab = state.tabs[state.activeTabIndex];
634
+
635
+ if (tab.fileType === 'image') {
636
+ alert('Cannot edit image files');
637
+ return;
638
+ }
639
+
640
+ state.isEditMode = !state.isEditMode;
641
+ this.updateButton();
642
+ state.isEditMode ? this.show() : this.hide();
643
+ },
644
+
645
+ updateButton() {
646
+ if (state.isEditMode) {
647
+ elements.editToggle.classList.add('active');
648
+ elements.editLabel.textContent = 'View';
649
+ } else {
650
+ elements.editToggle.classList.remove('active');
651
+ elements.editLabel.textContent = 'Edit';
652
+ }
653
+ },
654
+
655
+ show() {
656
+ if (state.activeTabIndex < 0) return;
657
+ const tab = state.tabs[state.activeTabIndex];
658
+
659
+ const viewTopLine = this.getViewTopLine();
660
+ const viewMaxScroll = elements.content.scrollHeight - elements.content.clientHeight;
661
+ let scrollPercentage = 0;
662
+ if (viewMaxScroll > 0) {
663
+ scrollPercentage = elements.content.scrollTop / viewMaxScroll;
664
+ }
665
+
666
+ elements.content.innerHTML = `
667
+ <div class="editor-container">
668
+ <textarea class="editor-textarea" id="editorTextarea" spellcheck="false">${escapeHtml(tab.raw || '')}</textarea>
669
+ </div>
670
+ `;
671
+
672
+ elements.editorStatus.style.display = 'inline';
673
+ elements.editorStatus.textContent = 'Ready';
674
+ elements.editorStatus.className = 'editor-status';
675
+
676
+ const textarea = document.getElementById('editorTextarea');
677
+ textarea.addEventListener('input', () => {
678
+ state.hasUnsavedChanges = true;
679
+ elements.editorStatus.textContent = 'Modified';
680
+ elements.editorStatus.className = 'editor-status modified';
681
+ });
682
+
683
+ setTimeout(() => {
684
+ textarea.focus();
685
+ if (viewTopLine >= 0) {
686
+ const lineHeight = this.getTextareaLineHeight(textarea);
687
+ textarea.scrollTop = viewTopLine * lineHeight;
688
+ } else if (scrollPercentage > 0) {
689
+ const editMaxScroll = textarea.scrollHeight - textarea.clientHeight;
690
+ textarea.scrollTop = editMaxScroll * scrollPercentage;
691
+ }
692
+ }, 0);
693
+ },
694
+
695
+ getViewTopLine() {
696
+ const contentRect = elements.content.getBoundingClientRect();
697
+ const topY = contentRect.top + 10;
698
+ const centerX = contentRect.left + contentRect.width / 2;
699
+
700
+ let el = document.elementFromPoint(centerX, topY);
701
+ if (!el || !elements.content.contains(el)) {
702
+ return -1;
703
+ }
704
+
705
+ while (el && el !== elements.content) {
706
+ const dataLine = el.getAttribute('data-line');
707
+ if (dataLine !== null) {
708
+ return parseInt(dataLine, 10);
709
+ }
710
+ el = el.parentElement;
711
+ }
712
+ return -1;
713
+ },
714
+
715
+ getTextareaLineHeight(textarea) {
716
+ const lines = textarea.value.split('\n');
717
+ if (lines.length > 0 && textarea.scrollHeight > 0) {
718
+ return textarea.scrollHeight / lines.length;
719
+ }
720
+ const style = window.getComputedStyle(textarea);
721
+ return parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.6;
722
+ },
723
+
724
+ hide() {
725
+ if (state.activeTabIndex < 0) return;
726
+ const tab = state.tabs[state.activeTabIndex];
727
+
728
+ const textarea = document.getElementById('editorTextarea');
729
+ let topLineNumber = -1;
730
+ let scrollPercentage = 0;
731
+
732
+ if (textarea) {
733
+ tab.raw = textarea.value;
734
+ topLineNumber = this.getEditTopLineNumber(textarea);
735
+ const maxScroll = textarea.scrollHeight - textarea.clientHeight;
736
+ if (maxScroll > 0) {
737
+ scrollPercentage = textarea.scrollTop / maxScroll;
738
+ }
739
+ }
740
+
741
+ elements.editorStatus.style.display = 'none';
742
+
743
+ state.skipScrollRestore = true;
744
+ TabManager.renderActive();
745
+ state.skipScrollRestore = false;
746
+
747
+ requestAnimationFrame(() => {
748
+ if (topLineNumber >= 0) {
749
+ const targetElement = this.findElementByLine(topLineNumber);
750
+ if (targetElement) {
751
+ const contentRect = elements.content.getBoundingClientRect();
752
+ const targetRect = targetElement.getBoundingClientRect();
753
+ const offsetTop = targetRect.top - contentRect.top + elements.content.scrollTop;
754
+ elements.content.scrollTop = offsetTop - 10;
755
+ return;
756
+ }
757
+ }
758
+ if (scrollPercentage > 0) {
759
+ const maxScroll = elements.content.scrollHeight - elements.content.clientHeight;
760
+ elements.content.scrollTop = maxScroll * scrollPercentage;
761
+ }
762
+ });
763
+ state.hasUnsavedChanges = false;
764
+ },
765
+
766
+ getEditTopLineNumber(textarea) {
767
+ const lineHeight = this.getTextareaLineHeight(textarea);
768
+ return Math.floor(textarea.scrollTop / lineHeight);
769
+ },
770
+
771
+ findElementByLine(lineNumber) {
772
+ const markdownBody = elements.content.querySelector('.markdown-body');
773
+ if (!markdownBody) return null;
774
+
775
+ const elementsWithLine = markdownBody.querySelectorAll('[data-line]');
776
+ let bestElement = null;
777
+ let bestLine = -1;
778
+
779
+ for (const el of elementsWithLine) {
780
+ const line = parseInt(el.getAttribute('data-line'), 10);
781
+ if (line <= lineNumber && line > bestLine) {
782
+ bestLine = line;
783
+ bestElement = el;
784
+ }
785
+ }
786
+
787
+ return bestElement;
788
+ },
789
+
790
+ async save() {
791
+ if (state.activeTabIndex < 0 || !state.isEditMode) return;
792
+
793
+ const tab = state.tabs[state.activeTabIndex];
794
+ const textarea = document.getElementById('editorTextarea');
795
+ if (!textarea) return;
796
+
797
+ const newContent = textarea.value;
798
+
799
+ try {
800
+ elements.editorStatus.textContent = 'Saving...';
801
+ elements.editorStatus.className = 'editor-status';
802
+
803
+ const response = await fetch('/api/file', {
804
+ method: 'POST',
805
+ headers: { 'Content-Type': 'application/json' },
806
+ body: JSON.stringify({ path: tab.path, content: newContent })
807
+ });
808
+
809
+ const result = await response.json();
810
+
811
+ if (result.error) {
812
+ elements.editorStatus.textContent = 'Error: ' + result.error;
813
+ elements.editorStatus.className = 'editor-status modified';
814
+ return;
815
+ }
816
+
817
+ tab.raw = newContent;
818
+ state.hasUnsavedChanges = false;
819
+ elements.editorStatus.textContent = 'Saved!';
820
+ elements.editorStatus.className = 'editor-status saved';
821
+
822
+ setTimeout(() => {
823
+ elements.editorStatus.textContent = 'Ready';
824
+ elements.editorStatus.className = 'editor-status';
825
+ }, 2000);
826
+
827
+ } catch (e) {
828
+ elements.editorStatus.textContent = 'Error: ' + e.message;
829
+ elements.editorStatus.className = 'editor-status modified';
830
+ }
831
+ },
832
+
833
+ init() {
834
+ elements.editToggle.addEventListener('click', () => this.toggle());
835
+ }
836
+ };
837
+
838
+ // ============================================================
839
+ // Print Manager
840
+ // ============================================================
841
+
842
+ const PrintManager = {
843
+ print() {
844
+ if (state.activeTabIndex < 0) return;
845
+
846
+ const tab = state.tabs[state.activeTabIndex];
847
+ const fileName = tab.name.replace(/\.(md|txt)$/, '') + '.pdf';
848
+
849
+ const content = elements.content.querySelector('.markdown-body');
850
+ if (!content) return;
851
+
852
+ const opt = {
853
+ margin: [15, 20, 15, 20],
854
+ filename: fileName,
855
+ image: { type: 'jpeg', quality: 0.98 },
856
+ html2canvas: { scale: 2, useCORS: true },
857
+ jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
858
+ pagebreak: { mode: ['avoid-all', 'css', 'legacy'] }
859
+ };
860
+
861
+ html2pdf().set(opt).from(content).save();
862
+ },
863
+
864
+ init() {
865
+ elements.printBtn.addEventListener('click', () => this.print());
866
+ }
867
+ };
868
+
869
+ // ============================================================
870
+ // Dialog Manager
871
+ // ============================================================
872
+
873
+ const DialogManager = {
874
+ currentCallback: null,
875
+ isConfirmDialog: false,
876
+
877
+ show(title, options = {}) {
878
+ elements.dialogTitle.textContent = title;
879
+ elements.dialogInput.style.display = options.showInput ? 'block' : 'none';
880
+ elements.dialogMessage.textContent = options.message || '';
881
+ elements.dialogMessage.style.display = options.message ? 'block' : 'none';
882
+
883
+ if (options.showInput) {
884
+ elements.dialogInput.value = options.defaultValue || '';
885
+ }
886
+
887
+ elements.dialogConfirm.className = options.danger ? 'btn-danger' : 'btn-confirm';
888
+ elements.dialogConfirm.textContent = options.confirmText || 'OK';
889
+
890
+ this.isConfirmDialog = options.isConfirm || false;
891
+ this.currentCallback = options.onConfirm;
892
+
893
+ elements.dialogOverlay.classList.remove('hidden');
894
+
895
+ if (options.showInput) {
896
+ setTimeout(() => {
897
+ elements.dialogInput.focus();
898
+ elements.dialogInput.select();
899
+ }, 100);
900
+ }
901
+ },
902
+
903
+ hide() {
904
+ elements.dialogOverlay.classList.add('hidden');
905
+ this.currentCallback = null;
906
+ },
907
+
908
+ confirm() {
909
+ if (this.currentCallback) {
910
+ const value = this.isConfirmDialog ? true : elements.dialogInput.value;
911
+ this.currentCallback(value);
912
+ }
913
+ this.hide();
914
+ },
915
+
916
+ init() {
917
+ elements.dialogCancel.addEventListener('click', () => this.hide());
918
+ elements.dialogConfirm.addEventListener('click', () => this.confirm());
919
+ elements.dialogInput.addEventListener('keydown', (e) => {
920
+ if (e.key === 'Enter') {
921
+ e.preventDefault();
922
+ this.confirm();
923
+ }
924
+ if (e.key === 'Escape') {
925
+ this.hide();
926
+ }
927
+ });
928
+ elements.dialogOverlay.addEventListener('click', (e) => {
929
+ if (e.target === elements.dialogOverlay) {
930
+ this.hide();
931
+ }
932
+ });
933
+ }
934
+ };
935
+
936
+ // ============================================================
937
+ // File Operations Manager
938
+ // ============================================================
939
+
940
+ const FileOperationsManager = {
941
+ async createDirectory(parentPath) {
942
+ DialogManager.show('新規フォルダ', {
943
+ showInput: true,
944
+ defaultValue: '新しいフォルダ',
945
+ onConfirm: async (name) => {
946
+ if (!name) return;
947
+ const path = parentPath ? `${parentPath}/${name}` : name;
948
+ try {
949
+ const response = await fetch('/api/mkdir', {
950
+ method: 'POST',
951
+ headers: { 'Content-Type': 'application/json' },
952
+ body: JSON.stringify({ path })
953
+ });
954
+ const result = await response.json();
955
+ if (!result.success) {
956
+ alert('Error: ' + (result.detail || result.error || 'Unknown error'));
957
+ }
958
+ } catch (e) {
959
+ alert('Error: ' + e.message);
960
+ }
961
+ }
962
+ });
963
+ },
964
+
965
+ async deleteItem(path, isDirectory) {
966
+ const name = path.split('/').pop();
967
+ const typeText = isDirectory ? 'フォルダ' : 'ファイル';
968
+ DialogManager.show(`${typeText}を削除`, {
969
+ message: `"${name}" を削除しますか?この操作は取り消せません。`,
970
+ isConfirm: true,
971
+ danger: true,
972
+ confirmText: '削除',
973
+ onConfirm: async () => {
974
+ try {
975
+ const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`, {
976
+ method: 'DELETE'
977
+ });
978
+ const result = await response.json();
979
+ if (!result.success) {
980
+ alert('Error: ' + (result.detail || result.error || 'Unknown error'));
981
+ } else {
982
+ const tabIndex = state.tabs.findIndex(t => t.path === path || t.path.startsWith(path + '/'));
983
+ if (tabIndex >= 0) {
984
+ TabManager.close(tabIndex);
985
+ }
986
+ }
987
+ } catch (e) {
988
+ alert('Error: ' + e.message);
989
+ }
990
+ }
991
+ });
992
+ },
993
+
994
+ async renameItem(path, isDirectory) {
995
+ const oldName = path.split('/').pop();
996
+ const parentPath = path.substring(0, path.lastIndexOf('/'));
997
+ DialogManager.show('名前を変更', {
998
+ showInput: true,
999
+ defaultValue: oldName,
1000
+ onConfirm: async (newName) => {
1001
+ if (!newName || newName === oldName) return;
1002
+ const destination = parentPath ? `${parentPath}/${newName}` : newName;
1003
+ try {
1004
+ const response = await fetch('/api/move', {
1005
+ method: 'POST',
1006
+ headers: { 'Content-Type': 'application/json' },
1007
+ body: JSON.stringify({ source: path, destination })
1008
+ });
1009
+ const result = await response.json();
1010
+ if (!result.success) {
1011
+ alert('Error: ' + (result.detail || result.error || 'Unknown error'));
1012
+ } else {
1013
+ let updated = false;
1014
+ state.tabs.forEach(tab => {
1015
+ if (tab.path === path) {
1016
+ tab.path = destination;
1017
+ tab.name = newName;
1018
+ updated = true;
1019
+ } else if (tab.path.startsWith(path + '/')) {
1020
+ tab.path = destination + tab.path.substring(path.length);
1021
+ updated = true;
1022
+ }
1023
+ });
1024
+ if (updated) {
1025
+ TabManager.render();
1026
+ }
1027
+ }
1028
+ } catch (e) {
1029
+ alert('Error: ' + e.message);
1030
+ }
1031
+ }
1032
+ });
1033
+ },
1034
+
1035
+ async moveItem(source, destinationFolder) {
1036
+ const fileName = source.split('/').pop();
1037
+ const destination = destinationFolder ? `${destinationFolder}/${fileName}` : fileName;
1038
+
1039
+ try {
1040
+ const response = await fetch('/api/move', {
1041
+ method: 'POST',
1042
+ headers: { 'Content-Type': 'application/json' },
1043
+ body: JSON.stringify({ source, destination })
1044
+ });
1045
+ const result = await response.json();
1046
+ if (!result.success) {
1047
+ alert('Error: ' + (result.detail || result.error || 'Unknown error'));
1048
+ } else {
1049
+ let updated = false;
1050
+ state.tabs.forEach(tab => {
1051
+ if (tab.path === source) {
1052
+ tab.path = destination;
1053
+ tab.name = fileName;
1054
+ updated = true;
1055
+ } else if (tab.path.startsWith(source + '/')) {
1056
+ tab.path = destination + tab.path.substring(source.length);
1057
+ updated = true;
1058
+ }
1059
+ });
1060
+ if (updated) {
1061
+ TabManager.render();
1062
+ }
1063
+ }
1064
+ } catch (e) {
1065
+ alert('Error: ' + e.message);
1066
+ }
1067
+ },
1068
+
1069
+ async upload(targetPath, files) {
1070
+ if (!files || files.length === 0) return;
1071
+
1072
+ elements.uploadOverlay.classList.remove('hidden');
1073
+ elements.uploadProgressFill.style.width = '0%';
1074
+ elements.uploadProgressText.textContent = '0%';
1075
+
1076
+ const formData = new FormData();
1077
+ formData.append('path', targetPath || '');
1078
+ for (const file of files) {
1079
+ formData.append('files', file);
1080
+ }
1081
+
1082
+ try {
1083
+ const xhr = new XMLHttpRequest();
1084
+ xhr.open('POST', '/api/upload');
1085
+
1086
+ xhr.upload.onprogress = (e) => {
1087
+ if (e.lengthComputable) {
1088
+ const percent = Math.round((e.loaded / e.total) * 100);
1089
+ elements.uploadProgressFill.style.width = percent + '%';
1090
+ elements.uploadProgressText.textContent = percent + '%';
1091
+ }
1092
+ };
1093
+
1094
+ xhr.onload = () => {
1095
+ elements.uploadOverlay.classList.add('hidden');
1096
+ if (xhr.status !== 200) {
1097
+ try {
1098
+ const result = JSON.parse(xhr.responseText);
1099
+ alert('Error: ' + (result.detail || result.error || 'Upload failed'));
1100
+ } catch {
1101
+ alert('Error: Upload failed');
1102
+ }
1103
+ }
1104
+ };
1105
+
1106
+ xhr.onerror = () => {
1107
+ elements.uploadOverlay.classList.add('hidden');
1108
+ alert('Upload failed');
1109
+ };
1110
+
1111
+ const fileName = files.length === 1 ? files[0].name : `${files.length}ファイル`;
1112
+ elements.uploadFileName.textContent = `${fileName} をアップロード中...`;
1113
+
1114
+ xhr.send(formData);
1115
+ } catch (e) {
1116
+ elements.uploadOverlay.classList.add('hidden');
1117
+ alert('Error: ' + e.message);
1118
+ }
1119
+ },
1120
+
1121
+ download(path) {
1122
+ const a = document.createElement('a');
1123
+ a.href = `/api/download?path=${encodeURIComponent(path)}`;
1124
+ a.download = '';
1125
+ document.body.appendChild(a);
1126
+ a.click();
1127
+ document.body.removeChild(a);
1128
+ }
1129
+ };
1130
+
1131
+ // ============================================================
1132
+ // Context Menu Manager
1133
+ // ============================================================
1134
+
1135
+ const ContextMenuManager = {
1136
+ currentPath: null,
1137
+ isDirectory: false,
1138
+
1139
+ show(x, y, path, isDir) {
1140
+ this.currentPath = path;
1141
+ this.isDirectory = isDir;
1142
+
1143
+ const items = this.getMenuItems(isDir, path);
1144
+ elements.contextMenu.innerHTML = items.map(item => {
1145
+ if (item.separator) {
1146
+ return '<div class="context-menu-separator"></div>';
1147
+ }
1148
+ return `<div class="context-menu-item ${item.danger ? 'danger' : ''}" data-action="${item.action}">${item.label}</div>`;
1149
+ }).join('');
1150
+
1151
+ const menuRect = elements.contextMenu.getBoundingClientRect();
1152
+ const maxX = window.innerWidth - 170;
1153
+ const maxY = window.innerHeight - (items.length * 36);
1154
+ elements.contextMenu.style.left = Math.min(x, maxX) + 'px';
1155
+ elements.contextMenu.style.top = Math.min(y, maxY) + 'px';
1156
+
1157
+ elements.contextMenu.classList.remove('hidden');
1158
+ },
1159
+
1160
+ hide() {
1161
+ elements.contextMenu.classList.add('hidden');
1162
+ this.currentPath = null;
1163
+ },
1164
+
1165
+ getMenuItems(isDir, path) {
1166
+ const pathDisplay = state.rootPath ? `${state.rootPath}/${path}` : path;
1167
+ if (isDir) {
1168
+ return [
1169
+ { label: '新規フォルダ', action: 'newFolder' },
1170
+ { label: 'アップロード', action: 'upload' },
1171
+ { separator: true },
1172
+ { label: '名前を変更', action: 'rename' },
1173
+ { label: 'パスをコピー', action: 'copyPath' },
1174
+ { separator: true },
1175
+ { label: '削除', action: 'delete', danger: true }
1176
+ ];
1177
+ } else {
1178
+ return [
1179
+ { label: '開く', action: 'open' },
1180
+ { label: 'ダウンロード', action: 'download' },
1181
+ { separator: true },
1182
+ { label: '名前を変更', action: 'rename' },
1183
+ { label: 'パスをコピー', action: 'copyPath' },
1184
+ { separator: true },
1185
+ { label: '削除', action: 'delete', danger: true }
1186
+ ];
1187
+ }
1188
+ },
1189
+
1190
+ handleAction(action) {
1191
+ const path = this.currentPath;
1192
+ const isDir = this.isDirectory;
1193
+ this.hide();
1194
+
1195
+ switch (action) {
1196
+ case 'open':
1197
+ TabManager.open(path);
1198
+ break;
1199
+ case 'download':
1200
+ FileOperationsManager.download(path);
1201
+ break;
1202
+ case 'rename':
1203
+ FileOperationsManager.renameItem(path, isDir);
1204
+ break;
1205
+ case 'delete':
1206
+ FileOperationsManager.deleteItem(path, isDir);
1207
+ break;
1208
+ case 'newFolder':
1209
+ FileOperationsManager.createDirectory(path);
1210
+ break;
1211
+ case 'upload':
1212
+ state.uploadTargetPath = path;
1213
+ elements.fileInput.click();
1214
+ break;
1215
+ case 'copyPath':
1216
+ const fullPath = state.rootPath ? `${state.rootPath}/${path}` : path;
1217
+ navigator.clipboard.writeText(fullPath).then(() => {
1218
+ console.log('パスをコピーしました:', fullPath);
1219
+ }).catch(err => {
1220
+ console.error('コピーに失敗:', err);
1221
+ alert('パスのコピーに失敗しました');
1222
+ });
1223
+ break;
1224
+ }
1225
+ },
1226
+
1227
+ init() {
1228
+ elements.contextMenu.addEventListener('click', (e) => {
1229
+ const item = e.target.closest('.context-menu-item');
1230
+ if (item) {
1231
+ this.handleAction(item.dataset.action);
1232
+ }
1233
+ });
1234
+
1235
+ document.addEventListener('click', (e) => {
1236
+ if (!elements.contextMenu.contains(e.target)) {
1237
+ this.hide();
1238
+ }
1239
+ });
1240
+
1241
+ document.addEventListener('contextmenu', (e) => {
1242
+ const treeItem = e.target.closest('.tree-item');
1243
+ if (treeItem && elements.fileTree.contains(treeItem)) {
1244
+ e.preventDefault();
1245
+ const path = treeItem.dataset.path;
1246
+ const isDir = !!treeItem.querySelector('.tree-children');
1247
+ this.show(e.clientX, e.clientY, path, isDir);
1248
+ }
1249
+ });
1250
+
1251
+ elements.fileTree.addEventListener('contextmenu', (e) => {
1252
+ if (e.target === elements.fileTree) {
1253
+ e.preventDefault();
1254
+ this.show(e.clientX, e.clientY, '', true);
1255
+ }
1256
+ });
1257
+
1258
+ elements.fileInput.addEventListener('change', (e) => {
1259
+ if (e.target.files.length > 0) {
1260
+ FileOperationsManager.upload(state.uploadTargetPath || '', e.target.files);
1261
+ e.target.value = '';
1262
+ }
1263
+ });
1264
+ }
1265
+ };
1266
+
1267
+ // ============================================================
1268
+ // Drag & Drop Manager
1269
+ // ============================================================
1270
+
1271
+ const DragDropManager = {
1272
+ draggedPath: null,
1273
+
1274
+ init() {
1275
+ elements.fileTree.addEventListener('dragstart', (e) => {
1276
+ const treeItem = e.target.closest('.tree-item');
1277
+ if (treeItem) {
1278
+ this.draggedPath = treeItem.dataset.path;
1279
+ e.dataTransfer.effectAllowed = 'move';
1280
+ e.dataTransfer.setData('text/plain', this.draggedPath);
1281
+ treeItem.style.opacity = '0.5';
1282
+ }
1283
+ });
1284
+
1285
+ elements.fileTree.addEventListener('dragend', (e) => {
1286
+ const treeItem = e.target.closest('.tree-item');
1287
+ if (treeItem) {
1288
+ treeItem.style.opacity = '';
1289
+ }
1290
+ this.draggedPath = null;
1291
+ document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
1292
+ });
1293
+
1294
+ elements.fileTree.addEventListener('dragover', (e) => {
1295
+ e.preventDefault();
1296
+ const treeItem = e.target.closest('.tree-item');
1297
+ if (treeItem && treeItem.querySelector('.tree-children')) {
1298
+ e.dataTransfer.dropEffect = 'move';
1299
+ treeItem.querySelector('.tree-item-content').classList.add('drag-over');
1300
+ }
1301
+ });
1302
+
1303
+ elements.fileTree.addEventListener('dragleave', (e) => {
1304
+ const treeItem = e.target.closest('.tree-item');
1305
+ if (treeItem) {
1306
+ treeItem.querySelector('.tree-item-content')?.classList.remove('drag-over');
1307
+ }
1308
+ });
1309
+
1310
+ elements.fileTree.addEventListener('drop', (e) => {
1311
+ e.preventDefault();
1312
+ document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
1313
+
1314
+ const treeItem = e.target.closest('.tree-item');
1315
+ if (!treeItem || !treeItem.querySelector('.tree-children')) return;
1316
+
1317
+ const targetPath = treeItem.dataset.path;
1318
+
1319
+ if (this.draggedPath && this.draggedPath !== targetPath) {
1320
+ if (targetPath.startsWith(this.draggedPath + '/')) {
1321
+ alert('フォルダを自身のサブフォルダに移動することはできません');
1322
+ return;
1323
+ }
1324
+ FileOperationsManager.moveItem(this.draggedPath, targetPath);
1325
+ }
1326
+ else if (e.dataTransfer.files.length > 0) {
1327
+ FileOperationsManager.upload(targetPath, e.dataTransfer.files);
1328
+ }
1329
+ });
1330
+
1331
+ const handleRootDrop = (e) => {
1332
+ if (e.target === elements.fileTree && e.dataTransfer.files.length > 0) {
1333
+ e.preventDefault();
1334
+ e.stopPropagation();
1335
+ elements.fileTree.classList.remove('drag-over');
1336
+ FileOperationsManager.upload('', e.dataTransfer.files);
1337
+ }
1338
+ };
1339
+
1340
+ elements.fileTree.addEventListener('dragover', (e) => {
1341
+ if (e.dataTransfer.types.includes('Files') && e.target === elements.fileTree) {
1342
+ e.preventDefault();
1343
+ elements.fileTree.classList.add('drag-over');
1344
+ }
1345
+ });
1346
+
1347
+ elements.fileTree.addEventListener('dragleave', (e) => {
1348
+ if (e.target === elements.fileTree) {
1349
+ elements.fileTree.classList.remove('drag-over');
1350
+ }
1351
+ });
1352
+
1353
+ elements.fileTree.addEventListener('drop', handleRootDrop);
1354
+ }
1355
+ };
1356
+
1357
+ // ============================================================
1358
+ // Keyboard Shortcuts
1359
+ // ============================================================
1360
+
1361
+ const KeyboardManager = {
1362
+ selectedTreePath: null,
1363
+
1364
+ init() {
1365
+ document.addEventListener('keydown', (e) => {
1366
+ const isMod = e.metaKey || e.ctrlKey;
1367
+
1368
+ if (isMod && e.key === 'b') {
1369
+ e.preventDefault();
1370
+ SidebarManager.toggle();
1371
+ }
1372
+ if (isMod && e.key === 'w' && state.activeTabIndex >= 0) {
1373
+ e.preventDefault();
1374
+ TabManager.close(state.activeTabIndex);
1375
+ }
1376
+ if (isMod && e.key === 'e' && state.activeTabIndex >= 0) {
1377
+ e.preventDefault();
1378
+ EditorManager.toggle();
1379
+ }
1380
+ if (isMod && e.key === 's' && state.isEditMode) {
1381
+ e.preventDefault();
1382
+ EditorManager.save();
1383
+ }
1384
+ if (isMod && e.key === 'p' && state.activeTabIndex >= 0) {
1385
+ e.preventDefault();
1386
+ PrintManager.print();
1387
+ }
1388
+
1389
+ if (this.selectedTreePath) {
1390
+ const activeItem = document.querySelector(`.tree-item[data-path="${this.selectedTreePath}"]`);
1391
+ const isDir = activeItem && !!activeItem.querySelector('.tree-children');
1392
+
1393
+ if (e.key === 'Delete' || e.key === 'Backspace') {
1394
+ if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
1395
+ e.preventDefault();
1396
+ FileOperationsManager.deleteItem(this.selectedTreePath, isDir);
1397
+ }
1398
+
1399
+ if (e.key === 'F2') {
1400
+ e.preventDefault();
1401
+ FileOperationsManager.renameItem(this.selectedTreePath, isDir);
1402
+ }
1403
+ }
1404
+ });
1405
+
1406
+ elements.fileTree.addEventListener('click', (e) => {
1407
+ const treeItem = e.target.closest('.tree-item');
1408
+ if (treeItem) {
1409
+ this.selectedTreePath = treeItem.dataset.path;
1410
+ }
1411
+ });
1412
+ }
1413
+ };
1414
+
1415
+ // ============================================================
1416
+ // Public API (Global Functions for onclick handlers)
1417
+ // ============================================================
1418
+
1419
+ window.MDV = {
1420
+ openFile: (path) => TabManager.open(path),
1421
+ switchTab: (index) => TabManager.switch(index),
1422
+ closeTab: (index) => TabManager.close(index),
1423
+ toggleDirectory: (element) => {
1424
+ element.querySelector('.chevron').classList.toggle('expanded');
1425
+ element.nextElementSibling.classList.toggle('collapsed');
1426
+ }
1427
+ };
1428
+
1429
+ // ============================================================
1430
+ // Initialize Application
1431
+ // ============================================================
1432
+
1433
+ async function init() {
1434
+ ThemeManager.init();
1435
+ SidebarManager.init();
1436
+ ResizeHandler.init();
1437
+ EditorManager.init();
1438
+ PrintManager.init();
1439
+ DialogManager.init();
1440
+ ContextMenuManager.init();
1441
+ DragDropManager.init();
1442
+ KeyboardManager.init();
1443
+ TabManager.render(); // 初期状態でタブバーを非表示
1444
+
1445
+ try {
1446
+ const infoResponse = await fetch('/api/info');
1447
+ const info = await infoResponse.json();
1448
+ state.rootPath = info.rootPath;
1449
+ } catch (e) {
1450
+ console.error('Failed to fetch server info:', e);
1451
+ }
1452
+
1453
+ await FileTreeManager.load();
1454
+ WebSocketManager.connect();
1455
+
1456
+ const params = new URLSearchParams(window.location.search);
1457
+ const initialFile = params.get('file');
1458
+ if (initialFile) {
1459
+ TabManager.open(initialFile);
1460
+ }
1461
+ }
1462
+
1463
+ init();
1464
+
1465
+ })();