ponkotsu-md-editor 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/markdown_editor.js +870 -0
- data/app/assets/stylesheets/markdown_editor.css +42 -0
- data/app/ponkotsu/md/editor/_editor.html.erb +102 -0
- data/app/ponkotsu/md/editor/_input_area.html.erb +22 -0
- data/app/ponkotsu/md/editor/_preview_area.html.erb +3 -0
- data/app/ponkotsu/md/editor/_toolbar.html.erb +150 -0
- data/lib/ponkotsu/md/editor/engine.rb +17 -0
- data/lib/ponkotsu/md/editor/helpers.rb +30 -0
- data/lib/ponkotsu/md/editor/version.rb +9 -0
- data/sig/ponkotsu/md/editor.rbs +8 -0
- metadata +73 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// DOM要素の安全な取得
|
|
5
|
+
function getElement(selector) {
|
|
6
|
+
try {
|
|
7
|
+
return document.querySelector(selector);
|
|
8
|
+
} catch (e) {
|
|
9
|
+
console.warn('Element not found:', selector);
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// DOM読み込み完了を待つ
|
|
15
|
+
function onReady(callback) {
|
|
16
|
+
if (document.readyState === 'loading') {
|
|
17
|
+
document.addEventListener('DOMContentLoaded', callback);
|
|
18
|
+
} else {
|
|
19
|
+
callback();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
onReady(function() {
|
|
24
|
+
console.log('Enhanced Markdown editor initializing...');
|
|
25
|
+
|
|
26
|
+
// DOM要素の取得
|
|
27
|
+
const textarea = getElement('.markdown-textarea');
|
|
28
|
+
const previewContainer = getElement('#markdownPreview');
|
|
29
|
+
const previewToggle = getElement('#previewToggle');
|
|
30
|
+
const titleField = getElement('#article_title');
|
|
31
|
+
const slugField = getElement('#article_slug');
|
|
32
|
+
const generateBtn = getElement('#generateSlugBtn');
|
|
33
|
+
const slugPreview = getElement('#slugPreview');
|
|
34
|
+
|
|
35
|
+
let isPreviewMode = false;
|
|
36
|
+
|
|
37
|
+
// Markdownテキスト挿入機能
|
|
38
|
+
window.insertMarkdown = function(before, after) {
|
|
39
|
+
after = after || '';
|
|
40
|
+
|
|
41
|
+
if (!textarea) {
|
|
42
|
+
console.warn('Textarea not found');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const start = textarea.selectionStart || 0;
|
|
48
|
+
const end = textarea.selectionEnd || 0;
|
|
49
|
+
const selectedText = textarea.value.substring(start, end);
|
|
50
|
+
|
|
51
|
+
const beforeText = textarea.value.substring(0, start);
|
|
52
|
+
const afterText = textarea.value.substring(end);
|
|
53
|
+
const newText = before + selectedText + after;
|
|
54
|
+
|
|
55
|
+
textarea.value = beforeText + newText + afterText;
|
|
56
|
+
|
|
57
|
+
// カーソル位置調整
|
|
58
|
+
const newCursorPos = selectedText.length > 0 ?
|
|
59
|
+
start + newText.length :
|
|
60
|
+
start + before.length;
|
|
61
|
+
|
|
62
|
+
textarea.focus();
|
|
63
|
+
if (textarea.setSelectionRange) {
|
|
64
|
+
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 入力イベントを発火
|
|
68
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
69
|
+
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.error('Error inserting markdown:', e);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
window.insertCode = function() {
|
|
76
|
+
const textarea = document.getElementById('article_content');
|
|
77
|
+
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
|
78
|
+
|
|
79
|
+
if (selectedText.includes('\n')) {
|
|
80
|
+
// 改行が含まれている場合はコードブロック
|
|
81
|
+
insertMarkdown('```\n', '\n```');
|
|
82
|
+
} else {
|
|
83
|
+
// 単一行の場合はコードスパン
|
|
84
|
+
insertMarkdown('`', '`');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// プレビュー切り替え機能
|
|
89
|
+
window.togglePreview = function() {
|
|
90
|
+
if (!textarea || !previewContainer) {
|
|
91
|
+
console.warn('Preview elements not found');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
isPreviewMode = !isPreviewMode;
|
|
97
|
+
|
|
98
|
+
if (isPreviewMode) {
|
|
99
|
+
// プレビューモードに切り替え
|
|
100
|
+
const markdownText = textarea.value || '';
|
|
101
|
+
const htmlContent = convertMarkdownToHtml(markdownText);
|
|
102
|
+
|
|
103
|
+
previewContainer.innerHTML = htmlContent;
|
|
104
|
+
previewContainer.style.display = 'block';
|
|
105
|
+
textarea.style.display = 'none';
|
|
106
|
+
|
|
107
|
+
if (previewToggle) {
|
|
108
|
+
previewToggle.innerHTML = '<i class="bi bi-pencil"></i> 編集に戻る';
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
// 編集モードに戻す
|
|
112
|
+
previewContainer.style.display = 'none';
|
|
113
|
+
textarea.style.display = 'block';
|
|
114
|
+
|
|
115
|
+
if (previewToggle) {
|
|
116
|
+
previewToggle.innerHTML = '<i class="bi bi-eye"></i> プレビュー';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error('Error toggling preview:', e);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// 許可するscriptタグのsrc(ホワイトリスト)
|
|
125
|
+
const ALLOWED_SCRIPT_SOURCES = [
|
|
126
|
+
'https://platform.twitter.com/widgets.js',
|
|
127
|
+
'https://www.youtube.com/iframe_api',
|
|
128
|
+
'https://connect.facebook.net/en_US/sdk.js',
|
|
129
|
+
'https://www.instagram.com/embed.js'
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// HTMLエンティティのマップ
|
|
133
|
+
const HTML_ENTITIES = {
|
|
134
|
+
'&': '&',
|
|
135
|
+
'<': '<',
|
|
136
|
+
'>': '>',
|
|
137
|
+
'"': '"',
|
|
138
|
+
''': "'",
|
|
139
|
+
''': "'",
|
|
140
|
+
'—': '—',
|
|
141
|
+
'–': '–',
|
|
142
|
+
'…': '…',
|
|
143
|
+
' ': ' ',
|
|
144
|
+
'©': '©',
|
|
145
|
+
'®': '®',
|
|
146
|
+
'™': '™',
|
|
147
|
+
'«': '«',
|
|
148
|
+
'»': '»',
|
|
149
|
+
'“': '"',
|
|
150
|
+
'”': '"',
|
|
151
|
+
'‘': '\'',
|
|
152
|
+
'’': '\'',
|
|
153
|
+
'•': '•',
|
|
154
|
+
'·': '·',
|
|
155
|
+
'§': '§',
|
|
156
|
+
'¶': '¶',
|
|
157
|
+
'†': '†',
|
|
158
|
+
'‡': '‡',
|
|
159
|
+
'‰': '‰',
|
|
160
|
+
'′': '′',
|
|
161
|
+
'″': '″',
|
|
162
|
+
'‹': '‹',
|
|
163
|
+
'›': '›',
|
|
164
|
+
'‾': '‾',
|
|
165
|
+
'⁄': '⁄',
|
|
166
|
+
'€': '€',
|
|
167
|
+
'ℑ': 'ℑ',
|
|
168
|
+
'℘': '℘',
|
|
169
|
+
'ℜ': 'ℜ',
|
|
170
|
+
'ℵ': 'ℵ',
|
|
171
|
+
'←': '←',
|
|
172
|
+
'↑': '↑',
|
|
173
|
+
'→': '→',
|
|
174
|
+
'↓': '↓',
|
|
175
|
+
'↔': '↔',
|
|
176
|
+
'↵': '↵'
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Twitter widgets.js の管理
|
|
180
|
+
class TwitterWidgetManager {
|
|
181
|
+
constructor() {
|
|
182
|
+
this.loaded = false;
|
|
183
|
+
this.loading = false;
|
|
184
|
+
this.loadPromise = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Twitter widgets.js を読み込み
|
|
188
|
+
async loadWidgets() {
|
|
189
|
+
if (this.loaded && window.twttr && window.twttr.widgets) {
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (this.loading) {
|
|
194
|
+
return this.loadPromise;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.loading = true;
|
|
198
|
+
this.loadPromise = new Promise((resolve, reject) => {
|
|
199
|
+
// 既に読み込まれている場合
|
|
200
|
+
if (window.twttr && window.twttr.widgets) {
|
|
201
|
+
this.loaded = true;
|
|
202
|
+
this.loading = false;
|
|
203
|
+
resolve();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 既存のscriptタグを削除
|
|
208
|
+
const existingScript = document.querySelector('script[src="https://platform.twitter.com/widgets.js"]');
|
|
209
|
+
if (existingScript) {
|
|
210
|
+
existingScript.remove();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 新しいscriptタグを作成
|
|
214
|
+
const script = document.createElement('script');
|
|
215
|
+
script.src = 'https://platform.twitter.com/widgets.js';
|
|
216
|
+
script.async = true;
|
|
217
|
+
script.charset = 'utf-8';
|
|
218
|
+
|
|
219
|
+
script.onload = () => {
|
|
220
|
+
this.loaded = true;
|
|
221
|
+
this.loading = false;
|
|
222
|
+
console.log('Twitter widgets.js loaded successfully');
|
|
223
|
+
resolve();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
script.onerror = (error) => {
|
|
227
|
+
this.loading = false;
|
|
228
|
+
console.error('Failed to load Twitter widgets.js:', error);
|
|
229
|
+
reject(error);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
document.head.appendChild(script);
|
|
233
|
+
|
|
234
|
+
// タイムアウト処理
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
if (!this.loaded) {
|
|
237
|
+
this.loading = false;
|
|
238
|
+
reject(new Error('Twitter widgets.js load timeout'));
|
|
239
|
+
}
|
|
240
|
+
}, 10000);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return this.loadPromise;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ツイートを初期化
|
|
247
|
+
async initializeTweets(container = document) {
|
|
248
|
+
try {
|
|
249
|
+
await this.loadWidgets();
|
|
250
|
+
|
|
251
|
+
if (window.twttr && window.twttr.widgets) {
|
|
252
|
+
await window.twttr.widgets.load(container);
|
|
253
|
+
console.log('Twitter widgets initialized');
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error('Failed to initialize Twitter widgets:', error);
|
|
257
|
+
this.showFallbackMessage(container);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// フォールバック表示
|
|
262
|
+
showFallbackMessage(container) {
|
|
263
|
+
const tweets = container.querySelectorAll('.twitter-tweet');
|
|
264
|
+
tweets.forEach(tweet => {
|
|
265
|
+
if (!tweet.querySelector('.twitter-widget')) {
|
|
266
|
+
tweet.style.border = '1px solid #e1e8ed';
|
|
267
|
+
tweet.style.borderRadius = '8px';
|
|
268
|
+
tweet.style.padding = '12px';
|
|
269
|
+
tweet.style.backgroundColor = '#f7f9fa';
|
|
270
|
+
tweet.style.color = '#14171a';
|
|
271
|
+
tweet.style.fontFamily = 'system-ui, -apple-system, sans-serif';
|
|
272
|
+
|
|
273
|
+
// エラーメッセージを追加
|
|
274
|
+
const errorDiv = document.createElement('div');
|
|
275
|
+
errorDiv.style.marginTop = '8px';
|
|
276
|
+
errorDiv.style.fontSize = '14px';
|
|
277
|
+
errorDiv.style.color = '#657786';
|
|
278
|
+
errorDiv.innerHTML = '⚠️ Twitter埋め込みの読み込みに失敗しました。上記のリンクから直接ツイートを確認してください。';
|
|
279
|
+
|
|
280
|
+
if (!tweet.querySelector('.twitter-error-message')) {
|
|
281
|
+
errorDiv.className = 'twitter-error-message';
|
|
282
|
+
tweet.appendChild(errorDiv);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// グローバルインスタンス
|
|
290
|
+
const twitterManager = new TwitterWidgetManager();
|
|
291
|
+
|
|
292
|
+
// 複合的なHTML構造の保護機能(スクリプト実行対応版)
|
|
293
|
+
class HTMLSanitizer {
|
|
294
|
+
constructor() {
|
|
295
|
+
this.protectedElements = [];
|
|
296
|
+
this.placeholderMap = new Map();
|
|
297
|
+
this.placeholderCounter = 0;
|
|
298
|
+
this.foundScripts = [];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// プレースホルダーの生成
|
|
302
|
+
generatePlaceholder(type = 'ELEMENT') {
|
|
303
|
+
const placeholder = `__PROTECTED_${type}_${this.placeholderCounter}__`;
|
|
304
|
+
this.placeholderCounter++;
|
|
305
|
+
return placeholder;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// HTMLエンティティを保護
|
|
309
|
+
protectHtmlEntities(html) {
|
|
310
|
+
Object.keys(HTML_ENTITIES).forEach(entity => {
|
|
311
|
+
const regex = new RegExp(entity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
|
|
312
|
+
html = html.replace(regex, (match) => {
|
|
313
|
+
const placeholder = this.generatePlaceholder('ENTITY');
|
|
314
|
+
this.protectedElements.push(match);
|
|
315
|
+
this.placeholderMap.set(placeholder, match);
|
|
316
|
+
return placeholder;
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
return html;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Twitter埋め込み全体を保護(スクリプト分離版)
|
|
323
|
+
protectTwitterEmbeds(html) {
|
|
324
|
+
// Twitter scriptタグを検出・保存
|
|
325
|
+
const scriptRegex = /<script[^>]*src="https:\/\/platform\.twitter\.com\/widgets\.js"[^>]*><\/script>/gi;
|
|
326
|
+
html = html.replace(scriptRegex, (match) => {
|
|
327
|
+
this.foundScripts.push(match);
|
|
328
|
+
return ''; // scriptタグは削除(後で手動実行)
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// blockquoteのみを保護
|
|
332
|
+
const blockquoteRegex = /<blockquote[^>]*class="twitter-tweet"[^>]*>[\s\S]*?<\/blockquote>/gi;
|
|
333
|
+
html = html.replace(blockquoteRegex, (match) => {
|
|
334
|
+
const placeholder = this.generatePlaceholder('TWITTER_BLOCKQUOTE');
|
|
335
|
+
this.protectedElements.push(match);
|
|
336
|
+
this.placeholderMap.set(placeholder, match);
|
|
337
|
+
return placeholder;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return html;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 個別のHTMLタグを保護
|
|
344
|
+
protectAllowedTags(html) {
|
|
345
|
+
const allowedTags = [
|
|
346
|
+
'blockquote', 'p', 'a', 'strong', 'em', 'code', 'del', 'b', 'i', 'u',
|
|
347
|
+
'br', 'hr', 'img', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
348
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'div', 'span'
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
allowedTags.forEach(tag => {
|
|
352
|
+
// 自己完結タグ
|
|
353
|
+
if (['br', 'hr', 'img'].includes(tag)) {
|
|
354
|
+
const regex = new RegExp(`<${tag}[^>]*\/?>`, 'gi');
|
|
355
|
+
html = html.replace(regex, (match) => {
|
|
356
|
+
if (this.isValidTag(tag, match)) {
|
|
357
|
+
const placeholder = this.generatePlaceholder(tag.toUpperCase());
|
|
358
|
+
this.protectedElements.push(match);
|
|
359
|
+
this.placeholderMap.set(placeholder, match);
|
|
360
|
+
return placeholder;
|
|
361
|
+
}
|
|
362
|
+
return match;
|
|
363
|
+
});
|
|
364
|
+
} else {
|
|
365
|
+
// 開始タグ
|
|
366
|
+
const openRegex = new RegExp(`<${tag}(\\s[^>]*)?\\s*>`, 'gi');
|
|
367
|
+
html = html.replace(openRegex, (match, attributes) => {
|
|
368
|
+
if (this.isValidTag(tag, match)) {
|
|
369
|
+
const placeholder = this.generatePlaceholder(`${tag.toUpperCase()}_OPEN`);
|
|
370
|
+
this.protectedElements.push(match);
|
|
371
|
+
this.placeholderMap.set(placeholder, match);
|
|
372
|
+
return placeholder;
|
|
373
|
+
}
|
|
374
|
+
return match;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// 終了タグ
|
|
378
|
+
const closeRegex = new RegExp(`</${tag}>`, 'gi');
|
|
379
|
+
html = html.replace(closeRegex, (match) => {
|
|
380
|
+
const placeholder = this.generatePlaceholder(`${tag.toUpperCase()}_CLOSE`);
|
|
381
|
+
this.protectedElements.push(match);
|
|
382
|
+
this.placeholderMap.set(placeholder, match);
|
|
383
|
+
return placeholder;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return html;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// タグの妥当性チェック
|
|
392
|
+
isValidTag(tagName, tagContent) {
|
|
393
|
+
const tag = tagName.toLowerCase();
|
|
394
|
+
|
|
395
|
+
// img、aタグのURL検証
|
|
396
|
+
if (tag === 'img' || tag === 'a') {
|
|
397
|
+
const srcMatch = tagContent.match(/(?:src|href)\s*=\s*["']([^"']+)["']/i);
|
|
398
|
+
if (srcMatch) {
|
|
399
|
+
return this.isValidUrl(srcMatch[1]);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// URL検証
|
|
407
|
+
isValidUrl(url) {
|
|
408
|
+
if (!url) return false;
|
|
409
|
+
|
|
410
|
+
// 相対パスは許可
|
|
411
|
+
if (url.startsWith('/')) return true;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const urlObj = new URL(url);
|
|
415
|
+
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 保護されたタグを復元
|
|
422
|
+
restoreProtectedElements(html) {
|
|
423
|
+
// 逆順で復元
|
|
424
|
+
const sortedPlaceholders = Array.from(this.placeholderMap.entries())
|
|
425
|
+
.sort((a, b) => {
|
|
426
|
+
const aNum = parseInt(a[0].match(/_(\d+)__$/)[1]);
|
|
427
|
+
const bNum = parseInt(b[0].match(/_(\d+)__$/)[1]);
|
|
428
|
+
return bNum - aNum;
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
sortedPlaceholders.forEach(([placeholder, original]) => {
|
|
432
|
+
html = html.replace(new RegExp(placeholder, 'g'), original);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
return html;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 検出されたスクリプトを取得
|
|
439
|
+
getFoundScripts() {
|
|
440
|
+
return this.foundScripts;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// リセット
|
|
444
|
+
reset() {
|
|
445
|
+
this.protectedElements = [];
|
|
446
|
+
this.placeholderMap.clear();
|
|
447
|
+
this.placeholderCounter = 0;
|
|
448
|
+
this.foundScripts = [];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Markdown→HTML変換(スクリプト実行対応版)
|
|
453
|
+
function convertMarkdownToHtml(markdown) {
|
|
454
|
+
if (!markdown || typeof markdown !== 'string') {
|
|
455
|
+
return '<p class="text-muted">内容がありません</p>';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const sanitizer = new HTMLSanitizer();
|
|
460
|
+
let html = markdown;
|
|
461
|
+
|
|
462
|
+
// 1. HTMLエンティティを保護
|
|
463
|
+
html = sanitizer.protectHtmlEntities(html);
|
|
464
|
+
|
|
465
|
+
// 2. Twitter埋め込みを保護(スクリプト分離)
|
|
466
|
+
html = sanitizer.protectTwitterEmbeds(html);
|
|
467
|
+
|
|
468
|
+
// 3. 個別のHTMLタグを保護
|
|
469
|
+
html = sanitizer.protectAllowedTags(html);
|
|
470
|
+
|
|
471
|
+
// 4. 残りのHTMLをエスケープ
|
|
472
|
+
html = html.replace(/&(?![a-zA-Z][a-zA-Z0-9]*;)/g, '&')
|
|
473
|
+
.replace(/</g, '<')
|
|
474
|
+
.replace(/>/g, '>')
|
|
475
|
+
.replace(/"/g, '"')
|
|
476
|
+
.replace(/'/g, ''');
|
|
477
|
+
|
|
478
|
+
// 5. Markdown変換
|
|
479
|
+
html = processMarkdown(html);
|
|
480
|
+
|
|
481
|
+
// 6. 保護されたHTMLタグとエンティティを復元
|
|
482
|
+
html = sanitizer.restoreProtectedElements(html);
|
|
483
|
+
|
|
484
|
+
// 7. 改行をbrタグに変換
|
|
485
|
+
html = html.replace(/\n/g, '<br>');
|
|
486
|
+
|
|
487
|
+
// 8. Twitter scriptが検出された場合は、Twitter widgets を初期化
|
|
488
|
+
const foundScripts = sanitizer.getFoundScripts();
|
|
489
|
+
if (foundScripts.length > 0) {
|
|
490
|
+
// 次のティック後にTwitter widgets を初期化
|
|
491
|
+
setTimeout(() => {
|
|
492
|
+
const previewContainer = document.getElementById('markdownPreview');
|
|
493
|
+
if (previewContainer && previewContainer.style.display !== 'none') {
|
|
494
|
+
twitterManager.initializeTweets(previewContainer);
|
|
495
|
+
}
|
|
496
|
+
}, 100);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return html || '<p class="text-muted">プレビュー内容がありません</p>';
|
|
500
|
+
|
|
501
|
+
} catch (e) {
|
|
502
|
+
console.error('Markdown conversion error:', e);
|
|
503
|
+
return '<p class="text-danger">プレビューエラー: ' + e.message + '</p>';
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Markdown処理関数
|
|
508
|
+
function processMarkdown(html) {
|
|
509
|
+
// コードブロック
|
|
510
|
+
html = html.replace(/```([\s\S]*?)```/g, '<pre class="bg-light p-3 rounded"><code>$1</code></pre>');
|
|
511
|
+
|
|
512
|
+
// インラインコード
|
|
513
|
+
html = html.replace(/`([^`]+)`/g, '<code class="bg-light px-1 rounded">$1</code>');
|
|
514
|
+
|
|
515
|
+
// 見出し
|
|
516
|
+
html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>');
|
|
517
|
+
html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>');
|
|
518
|
+
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
|
519
|
+
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
520
|
+
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
521
|
+
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
522
|
+
|
|
523
|
+
// 水平線
|
|
524
|
+
html = html.replace(/^---$/gm, '<hr>');
|
|
525
|
+
|
|
526
|
+
// 太字・斜体・打ち消し線
|
|
527
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
528
|
+
html = html.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
|
529
|
+
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
|
530
|
+
|
|
531
|
+
// 引用
|
|
532
|
+
html = html.replace(/^> (.+)$/gm, '<blockquote class="blockquote border-start border-3 border-secondary ps-3 my-3"><p class="mb-0">$1</p></blockquote>');
|
|
533
|
+
|
|
534
|
+
// チェックボックス
|
|
535
|
+
html = html.replace(/^- \[x\] (.+)$/gm, '<div class="form-check my-2"><input class="form-check-input" type="checkbox" checked disabled><label class="form-check-label text-decoration-line-through">$1</label></div>');
|
|
536
|
+
html = html.replace(/^- \[ \] (.+)$/gm, '<div class="form-check my-2"><input class="form-check-input" type="checkbox" disabled><label class="form-check-label">$1</label></div>');
|
|
537
|
+
|
|
538
|
+
// 番号付きリスト
|
|
539
|
+
html = html.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>');
|
|
540
|
+
|
|
541
|
+
// Markdown画像
|
|
542
|
+
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
|
|
543
|
+
if (isValidUrl(url)) {
|
|
544
|
+
return `<img src="${url}" alt="${alt}" class="img-fluid rounded my-2" style="max-width: 100%; height: auto;" loading="lazy">`;
|
|
545
|
+
}
|
|
546
|
+
return match;
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// リンク
|
|
550
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
|
551
|
+
if (isValidUrl(url)) {
|
|
552
|
+
return `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color: var(--accent-pink); text-decoration: none;">${text}</a>`;
|
|
553
|
+
}
|
|
554
|
+
return text;
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// 通常のリスト項目
|
|
558
|
+
html = html.replace(/^- (.+)$/gm, '<li class="mb-1">$1</li>');
|
|
559
|
+
|
|
560
|
+
// リストをul/ol要素で囲む
|
|
561
|
+
html = wrapListItems(html);
|
|
562
|
+
|
|
563
|
+
return html;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// URL検証関数
|
|
567
|
+
function isValidUrl(url) {
|
|
568
|
+
if (!url) return false;
|
|
569
|
+
if (url.startsWith('/')) return true;
|
|
570
|
+
try {
|
|
571
|
+
const urlObj = new URL(url);
|
|
572
|
+
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
|
|
573
|
+
} catch {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// リストアイテムをul/ol要素で囲む
|
|
579
|
+
function wrapListItems(html) {
|
|
580
|
+
const lines = html.split('\n');
|
|
581
|
+
let inUnorderedList = false;
|
|
582
|
+
let inOrderedList = false;
|
|
583
|
+
const processedLines = [];
|
|
584
|
+
|
|
585
|
+
for (let i = 0; i < lines.length; i++) {
|
|
586
|
+
const line = lines[i];
|
|
587
|
+
const isUnorderedListItem = line.includes('<li class="mb-1">') && !line.match(/^\d+\./);
|
|
588
|
+
const isOrderedListItem = line.includes('<li class="mb-1">') && line.match(/^\d+\./);
|
|
589
|
+
|
|
590
|
+
if (isUnorderedListItem) {
|
|
591
|
+
if (!inUnorderedList) {
|
|
592
|
+
if (inOrderedList) {
|
|
593
|
+
processedLines.push('</ol>');
|
|
594
|
+
inOrderedList = false;
|
|
595
|
+
}
|
|
596
|
+
processedLines.push('<ul class="my-3">');
|
|
597
|
+
inUnorderedList = true;
|
|
598
|
+
}
|
|
599
|
+
processedLines.push(line);
|
|
600
|
+
} else if (isOrderedListItem) {
|
|
601
|
+
if (!inOrderedList) {
|
|
602
|
+
if (inUnorderedList) {
|
|
603
|
+
processedLines.push('</ul>');
|
|
604
|
+
inUnorderedList = false;
|
|
605
|
+
}
|
|
606
|
+
processedLines.push('<ol class="my-3">');
|
|
607
|
+
inOrderedList = true;
|
|
608
|
+
}
|
|
609
|
+
processedLines.push(line);
|
|
610
|
+
} else {
|
|
611
|
+
if (inUnorderedList) {
|
|
612
|
+
processedLines.push('</ul>');
|
|
613
|
+
inUnorderedList = false;
|
|
614
|
+
}
|
|
615
|
+
if (inOrderedList) {
|
|
616
|
+
processedLines.push('</ol>');
|
|
617
|
+
inOrderedList = false;
|
|
618
|
+
}
|
|
619
|
+
processedLines.push(line);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (inUnorderedList) {
|
|
624
|
+
processedLines.push('</ul>');
|
|
625
|
+
}
|
|
626
|
+
if (inOrderedList) {
|
|
627
|
+
processedLines.push('</ol>');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return processedLines.join('\n');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// プレビュー切り替え時にTwitter widgets を初期化
|
|
634
|
+
const originalTogglePreview = window.togglePreview;
|
|
635
|
+
window.togglePreview = function() {
|
|
636
|
+
if (originalTogglePreview) {
|
|
637
|
+
originalTogglePreview.call(this);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// プレビューモードに切り替わった場合
|
|
641
|
+
const previewContainer = document.getElementById('markdownPreview');
|
|
642
|
+
if (previewContainer && previewContainer.style.display !== 'none') {
|
|
643
|
+
// Twitter埋め込みがある場合は初期化
|
|
644
|
+
const twitterTweets = previewContainer.querySelectorAll('.twitter-tweet');
|
|
645
|
+
if (twitterTweets.length > 0) {
|
|
646
|
+
twitterManager.initializeTweets(previewContainer);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
// デバッグ用:サニタイズテスト
|
|
652
|
+
window.testSanitization = function() {
|
|
653
|
+
const testInput = `<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ニーアでも聞きながら作業しますかね。<a href="https://t.co/Ac9X8lEaZL">https://t.co/Ac9X8lEaZL</a></p>— ボイラー (@dhq_boiler) <a href="https://twitter.com/dhq_boiler/status/1942584009550409780?ref_src=twsrc%5Etfw">July 8, 2025</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`;
|
|
654
|
+
|
|
655
|
+
const result = convertMarkdownToHtml(testInput);
|
|
656
|
+
console.log('Original:', testInput);
|
|
657
|
+
console.log('Converted:', result);
|
|
658
|
+
return result;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// Twitter埋め込み用のヘルパー関数
|
|
662
|
+
window.insertTwitterEmbed = function() {
|
|
663
|
+
const tweetUrl = prompt('TwitterのツイートURLを入力してください:');
|
|
664
|
+
if (!tweetUrl) return;
|
|
665
|
+
|
|
666
|
+
// Twitter URL検証
|
|
667
|
+
if (!tweetUrl.match(/^https?:\/\/(twitter\.com|x\.com)\/.+\/status\/\d+/)) {
|
|
668
|
+
alert('有効なTwitterのツイートURLを入力してください。\n例: https://twitter.com/username/status/1234567890');
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const textarea = document.getElementById('article_content');
|
|
673
|
+
if (!textarea) return;
|
|
674
|
+
|
|
675
|
+
// 簡単なTwitter埋め込みコードのテンプレート
|
|
676
|
+
const embedCode = `
|
|
677
|
+
<blockquote class="twitter-tweet">
|
|
678
|
+
<p>Loading tweet...</p>
|
|
679
|
+
<a href="${tweetUrl}">View Tweet</a>
|
|
680
|
+
</blockquote>
|
|
681
|
+
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
|
|
682
|
+
`;
|
|
683
|
+
|
|
684
|
+
insertTextAtCursor(textarea, embedCode);
|
|
685
|
+
|
|
686
|
+
alert('基本的なTwitter埋め込みを挿入しました。\n\n完全な埋め込みには、Twitter公式から生成されたコードを使用してください。');
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// 遅延実行のためのデバウンス関数
|
|
690
|
+
function debounce(func, wait) {
|
|
691
|
+
let timeout;
|
|
692
|
+
return function executedFunction(...args) {
|
|
693
|
+
const later = () => {
|
|
694
|
+
clearTimeout(timeout);
|
|
695
|
+
func(...args);
|
|
696
|
+
};
|
|
697
|
+
clearTimeout(timeout);
|
|
698
|
+
timeout = setTimeout(later, wait);
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// キーボードショートカット
|
|
703
|
+
if (textarea) {
|
|
704
|
+
textarea.addEventListener('keydown', function(e) {
|
|
705
|
+
try {
|
|
706
|
+
// Ctrl+B: 太字
|
|
707
|
+
if (e.ctrlKey && e.key === 'b') {
|
|
708
|
+
e.preventDefault();
|
|
709
|
+
window.insertMarkdown('**', '**');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Ctrl+I: 斜体
|
|
713
|
+
if (e.ctrlKey && e.key === 'i') {
|
|
714
|
+
e.preventDefault();
|
|
715
|
+
window.insertMarkdown('*', '*');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Ctrl+K: リンク
|
|
719
|
+
if (e.ctrlKey && e.key === 'k') {
|
|
720
|
+
e.preventDefault();
|
|
721
|
+
window.insertMarkdown('[', '](https://)');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Tab: インデント
|
|
725
|
+
if (e.key === 'Tab') {
|
|
726
|
+
e.preventDefault();
|
|
727
|
+
window.insertMarkdown(' '); // 2スペースのインデント
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Ctrl+Shift+P: プレビュー切り替え
|
|
731
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
|
|
732
|
+
e.preventDefault();
|
|
733
|
+
window.togglePreview();
|
|
734
|
+
}
|
|
735
|
+
} catch (error) {
|
|
736
|
+
console.error('Keyboard shortcut error:', error);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// フォームバリデーション
|
|
742
|
+
const form = getElement('.article-form');
|
|
743
|
+
if (form) {
|
|
744
|
+
form.addEventListener('submit', function(e) {
|
|
745
|
+
const title = titleField && titleField.value.trim();
|
|
746
|
+
const content = textarea && textarea.value.trim();
|
|
747
|
+
|
|
748
|
+
if (!title) {
|
|
749
|
+
e.preventDefault();
|
|
750
|
+
alert('タイトルを入力してください');
|
|
751
|
+
if (titleField) {
|
|
752
|
+
titleField.focus();
|
|
753
|
+
titleField.scrollIntoView({ behavior: 'smooth' });
|
|
754
|
+
}
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (!content) {
|
|
759
|
+
e.preventDefault();
|
|
760
|
+
alert('本文を入力してください');
|
|
761
|
+
if (textarea) {
|
|
762
|
+
textarea.focus();
|
|
763
|
+
textarea.scrollIntoView({ behavior: 'smooth' });
|
|
764
|
+
}
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// スラッグが空の場合は最後の努力で生成
|
|
769
|
+
if (slugField && !slugField.value.trim() && title) {
|
|
770
|
+
e.preventDefault();
|
|
771
|
+
|
|
772
|
+
generateHighQualitySlug(title).then(slug => {
|
|
773
|
+
if (slug) {
|
|
774
|
+
slugField.value = slug;
|
|
775
|
+
form.submit(); // 再送信
|
|
776
|
+
} else {
|
|
777
|
+
alert('スラッグの生成に失敗しました。手動で入力してください。');
|
|
778
|
+
slugField.focus();
|
|
779
|
+
}
|
|
780
|
+
}).catch(error => {
|
|
781
|
+
console.error('Final slug generation failed:', error);
|
|
782
|
+
alert('スラッグの生成でエラーが発生しました。手動で入力してください。');
|
|
783
|
+
slugField.focus();
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// スラッグの形式チェック
|
|
790
|
+
if (slugField && slugField.value.trim()) {
|
|
791
|
+
const slug = slugField.value.trim();
|
|
792
|
+
if (!/^[a-z0-9\-]+$/.test(slug)) {
|
|
793
|
+
e.preventDefault();
|
|
794
|
+
alert('スラッグは英小文字、数字、ハイフンのみ使用できます');
|
|
795
|
+
slugField.focus();
|
|
796
|
+
slugField.select();
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// リアルタイムバリデーション
|
|
804
|
+
if (slugField) {
|
|
805
|
+
slugField.addEventListener('blur', function() {
|
|
806
|
+
const slug = this.value.trim();
|
|
807
|
+
if (slug && !/^[a-z0-9\-]+$/.test(slug)) {
|
|
808
|
+
this.classList.add('is-invalid');
|
|
809
|
+
|
|
810
|
+
// エラーメッセージを表示
|
|
811
|
+
let errorDiv = this.parentNode.querySelector('.invalid-feedback');
|
|
812
|
+
if (!errorDiv) {
|
|
813
|
+
errorDiv = document.createElement('div');
|
|
814
|
+
errorDiv.className = 'invalid-feedback';
|
|
815
|
+
this.parentNode.appendChild(errorDiv);
|
|
816
|
+
}
|
|
817
|
+
errorDiv.textContent = '英小文字、数字、ハイフンのみ使用できます';
|
|
818
|
+
} else {
|
|
819
|
+
this.classList.remove('is-invalid');
|
|
820
|
+
const errorDiv = this.parentNode.querySelector('.invalid-feedback');
|
|
821
|
+
if (errorDiv) {
|
|
822
|
+
errorDiv.remove();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
console.log('ponkotsu Markdown editor initialized successfully');
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// ページ離脱確認
|
|
832
|
+
window.addEventListener('beforeunload', function(e) {
|
|
833
|
+
try {
|
|
834
|
+
const textarea = getElement('.markdown-textarea');
|
|
835
|
+
const titleField = getElement('#article_title');
|
|
836
|
+
|
|
837
|
+
const hasContent = (textarea && textarea.value.trim()) ||
|
|
838
|
+
(titleField && titleField.value.trim());
|
|
839
|
+
|
|
840
|
+
if (hasContent) {
|
|
841
|
+
const message = '編集中の内容が失われますがよろしいですか?';
|
|
842
|
+
e.returnValue = message;
|
|
843
|
+
return message;
|
|
844
|
+
}
|
|
845
|
+
} catch (error) {
|
|
846
|
+
console.error('Beforeunload error:', error);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// エラーハンドリングの強化
|
|
851
|
+
window.addEventListener('error', function(e) {
|
|
852
|
+
if (e.message.includes('markdown') || e.message.includes('slug')) {
|
|
853
|
+
console.error('Markdown Editor Error:', e);
|
|
854
|
+
// ユーザーフレンドリーなエラー表示
|
|
855
|
+
const errorDiv = document.createElement('div');
|
|
856
|
+
errorDiv.className = 'alert alert-warning alert-dismissible fade show';
|
|
857
|
+
errorDiv.innerHTML = `
|
|
858
|
+
<strong>エディタでエラーが発生しました</strong><br>
|
|
859
|
+
ページを再読み込みしてください。問題が続く場合は管理者にお問い合わせください。
|
|
860
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
861
|
+
`;
|
|
862
|
+
|
|
863
|
+
const container = document.querySelector('.container');
|
|
864
|
+
if (container) {
|
|
865
|
+
container.insertBefore(errorDiv, container.firstChild);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
})();
|