ponkotsu-md-editor 0.2.38 → 0.3.1
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 +4 -4
- data/.ruby-version +1 -0
- data/E2E_TESTING.md +256 -0
- data/README.md +29 -2
- data/app/assets/javascripts/markdown_editor.js +545 -187
- data/lib/ponkotsu/md/editor/helpers.rb +2 -1
- data/lib/ponkotsu/md/editor/version.rb +1 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69a042675752a90ebfb4fea9302b8afdcdc4ef5b5a0a92d1060bb5c591c591c9
|
|
4
|
+
data.tar.gz: 1ba8795a6ce803ddc14c64df8d709e46829bbcfc36915161d4577fb947ad2e33
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e21f8d1f60a1cbedec438c055b9522a27714dd1bb874130b89d182fe9b62ea15fc5930f576b46b2df84ac79628b662ba14fcf0587ef571cf9766eda9caa068b0
|
|
7
|
+
data.tar.gz: 021bb8b7babf193b14a9baada9ced767fcacdc8b09d1990c0453dcc384b62aaea8f40366db51037e8ce09e216fadd637473cc0372e9161975f90ac551a3365b2
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.7
|
data/E2E_TESTING.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# E2E Testing Guide for Ponkotsu Markdown Editor
|
|
2
|
+
|
|
3
|
+
このドキュメントでは、ponkotsu-md-editorのEnd-to-End(E2E)テストの実行方法について説明します。
|
|
4
|
+
|
|
5
|
+
## 概要
|
|
6
|
+
|
|
7
|
+
E2Eテストは、実際のブラウザ環境でMarkdownエディタの機能を検証します。以下のブラウザでテストを実行できます:
|
|
8
|
+
|
|
9
|
+
- Google Chrome (Headless)
|
|
10
|
+
- Mozilla Firefox (Headless)
|
|
11
|
+
|
|
12
|
+
## 前提条件
|
|
13
|
+
|
|
14
|
+
### 必要なソフトウェア
|
|
15
|
+
|
|
16
|
+
1. **Ruby** (>= 3.4.7)
|
|
17
|
+
2. **Google Chrome** (最新版)
|
|
18
|
+
3. **Mozilla Firefox** (最新版)
|
|
19
|
+
4. **ChromeDriver** (webdrivers gemが自動的に管理)
|
|
20
|
+
5. **GeckoDriver** (webdrivers gemが自動的に管理)
|
|
21
|
+
|
|
22
|
+
### Gemのインストール
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
必要なgemは以下の通りです:
|
|
29
|
+
- `rspec`: テストフレームワーク
|
|
30
|
+
- `capybara`: ブラウザ自動化ツール
|
|
31
|
+
- `selenium-webdriver`: WebDriverプロトコルの実装
|
|
32
|
+
- `webdrivers`: ブラウザドライバーの自動管理
|
|
33
|
+
|
|
34
|
+
## テストファイルの構成
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
spec/
|
|
38
|
+
├── spec_helper.rb # RSpecとCapybaraの設定
|
|
39
|
+
├── support/
|
|
40
|
+
│ └── e2e_helpers.rb # E2Eテスト用ヘルパーメソッド
|
|
41
|
+
└── features/
|
|
42
|
+
├── markdown_editor_e2e_spec.rb # 包括的なE2Eテスト
|
|
43
|
+
└── bold_functionality_spec.rb # Bold機能の詳細テスト
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## テストの実行方法
|
|
47
|
+
|
|
48
|
+
### すべてのテストを実行
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bundle exec rspec spec/features/markdown_editor_e2e_spec.rb
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 特定のブラウザでのみ実行
|
|
55
|
+
|
|
56
|
+
**Chromeのみ:**
|
|
57
|
+
```bash
|
|
58
|
+
bundle exec rspec spec/features/markdown_editor_e2e_spec.rb -e "Testing with Chrome"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Firefoxのみ:**
|
|
62
|
+
```bash
|
|
63
|
+
bundle exec rspec spec/features/markdown_editor_e2e_spec.rb -e "Testing with Firefox"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 特定のテストカテゴリを実行
|
|
67
|
+
|
|
68
|
+
**基本操作のみ:**
|
|
69
|
+
```bash
|
|
70
|
+
bundle exec rspec spec/features/markdown_editor_e2e_spec.rb -e "Basic operations"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**装飾ボタンのみ:**
|
|
74
|
+
```bash
|
|
75
|
+
bundle exec rspec spec/features/markdown_editor_e2e_spec.rb -e "Formatting buttons"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**プレビュー機能のみ:**
|
|
79
|
+
```bash
|
|
80
|
+
bundle exec rspec spec/features/markdown_editor_e2e_spec.rb -e "Preview functionality"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### ヘッドレスモードを無効にしてテストを実行
|
|
84
|
+
|
|
85
|
+
ブラウザの動作を視覚的に確認したい場合は、spec_helper.rbを編集してヘッドレスオプションを削除します:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# spec/spec_helper.rbの該当箇所をコメントアウト
|
|
89
|
+
# options.add_argument("--headless")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## テスト項目
|
|
93
|
+
|
|
94
|
+
### 1. 基本操作テスト
|
|
95
|
+
|
|
96
|
+
- **テキスト挿入**
|
|
97
|
+
- 英数字のテキスト挿入
|
|
98
|
+
- 日本語テキストの挿入
|
|
99
|
+
- 改行の保持
|
|
100
|
+
- 複数の連続した改行の挿入
|
|
101
|
+
|
|
102
|
+
- **テキスト削除**
|
|
103
|
+
- 選択範囲の削除
|
|
104
|
+
- コンテンツ全体のクリア
|
|
105
|
+
|
|
106
|
+
- **カーソル位置**
|
|
107
|
+
- テキスト挿入後のカーソル位置維持
|
|
108
|
+
|
|
109
|
+
### 2. 装飾ボタン機能テスト
|
|
110
|
+
|
|
111
|
+
各Markdown装飾機能が正しく動作することを確認:
|
|
112
|
+
|
|
113
|
+
- **Bold** (`**text**`)
|
|
114
|
+
- **Italic** (`*text*`)
|
|
115
|
+
- **Strikethrough** (`~~text~~`)
|
|
116
|
+
- **Code** (`` `code` `` or ` ```code block``` `)
|
|
117
|
+
- **Headings** (`# H1`, `## H2`, `### H3`, etc.)
|
|
118
|
+
- **Lists**
|
|
119
|
+
- Unordered list (`- item`)
|
|
120
|
+
- Ordered list (`1. item`)
|
|
121
|
+
- Checkbox list (`- [ ] task`, `- [x] done`)
|
|
122
|
+
- **Blockquote** (`> quote`)
|
|
123
|
+
- **Link** (`[text](url)`)
|
|
124
|
+
- **Image** (``)
|
|
125
|
+
- **Table** (マークダウンテーブル構文)
|
|
126
|
+
- **Horizontal Rule** (`---`)
|
|
127
|
+
|
|
128
|
+
### 3. 複数の装飾操作テスト
|
|
129
|
+
|
|
130
|
+
- 異なるテキスト範囲への複数の装飾適用
|
|
131
|
+
- ネストされた装飾の処理
|
|
132
|
+
|
|
133
|
+
### 4. プレビュー機能テスト
|
|
134
|
+
|
|
135
|
+
- プレビューの表示/非表示切り替え
|
|
136
|
+
- MarkdownからHTMLへの変換
|
|
137
|
+
|
|
138
|
+
### 5. エッジケースとエラーハンドリング
|
|
139
|
+
|
|
140
|
+
- 空のエディタでの操作
|
|
141
|
+
- 非常に長いテキストの処理
|
|
142
|
+
- 特殊文字の処理
|
|
143
|
+
- Unicode文字(絵文字、多言語)の処理
|
|
144
|
+
|
|
145
|
+
## テストのデバッグ
|
|
146
|
+
|
|
147
|
+
### ログの有効化
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# spec/spec_helper.rbに追加
|
|
151
|
+
RSpec.configure do |config|
|
|
152
|
+
config.before(:each, type: :feature) do
|
|
153
|
+
page.driver.browser.manage.logs.get(:browser).each do |log|
|
|
154
|
+
puts log.message
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### スクリーンショットの取得
|
|
161
|
+
|
|
162
|
+
テスト失敗時にスクリーンショットを保存:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# テストケース内で
|
|
166
|
+
save_screenshot('debug_screenshot.png')
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### ブラウザコンソールログの確認
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
page.driver.browser.manage.logs.get(:browser).each do |log|
|
|
173
|
+
puts "[#{log.level}] #{log.message}"
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## トラブルシューティング
|
|
178
|
+
|
|
179
|
+
### ChromeDriverのバージョンエラー
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# webdriversキャッシュをクリア
|
|
183
|
+
rm -rf ~/.webdrivers
|
|
184
|
+
bundle exec rspec spec/features/markdown_editor_e2e_spec.rb
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### GeckoDriverのエラー
|
|
188
|
+
|
|
189
|
+
Firefoxが最新版であることを確認してください:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# macOS
|
|
193
|
+
brew upgrade firefox
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### タイムアウトエラー
|
|
197
|
+
|
|
198
|
+
Capybaraのタイムアウト設定を増やす:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
# spec/spec_helper.rb
|
|
202
|
+
Capybara.configure do |config|
|
|
203
|
+
config.default_max_wait_time = 20 # デフォルトは10秒
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## CI/CD環境での実行
|
|
208
|
+
|
|
209
|
+
### GitHub Actions設定例
|
|
210
|
+
|
|
211
|
+
```yaml
|
|
212
|
+
name: E2E Tests
|
|
213
|
+
|
|
214
|
+
on: [push, pull_request]
|
|
215
|
+
|
|
216
|
+
jobs:
|
|
217
|
+
e2e:
|
|
218
|
+
runs-on: ubuntu-latest
|
|
219
|
+
|
|
220
|
+
steps:
|
|
221
|
+
- uses: actions/checkout@v3
|
|
222
|
+
|
|
223
|
+
- name: Set up Ruby
|
|
224
|
+
uses: ruby/setup-ruby@v1
|
|
225
|
+
with:
|
|
226
|
+
ruby-version: 3.4.7
|
|
227
|
+
bundler-cache: true
|
|
228
|
+
|
|
229
|
+
- name: Install Chrome
|
|
230
|
+
run: |
|
|
231
|
+
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
|
232
|
+
sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
|
|
233
|
+
sudo apt-get update
|
|
234
|
+
sudo apt-get install -y google-chrome-stable
|
|
235
|
+
|
|
236
|
+
- name: Install Firefox
|
|
237
|
+
run: sudo apt-get install -y firefox
|
|
238
|
+
|
|
239
|
+
- name: Run E2E tests
|
|
240
|
+
run: bundle exec rspec spec/features/markdown_editor_e2e_spec.rb
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## ベストプラクティス
|
|
244
|
+
|
|
245
|
+
1. **テストの独立性**: 各テストは独立して実行可能にする
|
|
246
|
+
2. **適切な待機時間**: `sleep`の代わりにCapybaraの待機メソッドを使用
|
|
247
|
+
3. **クリーンアップ**: 各テスト後にエディタの状態をリセット
|
|
248
|
+
4. **エラーメッセージ**: 失敗時に有用な情報を提供
|
|
249
|
+
5. **並列実行**: テストを並列実行して時間を短縮(`parallel_tests` gemを使用)
|
|
250
|
+
|
|
251
|
+
## 参考リンク
|
|
252
|
+
|
|
253
|
+
- [Capybara Documentation](https://github.com/teamcapybara/capybara)
|
|
254
|
+
- [Selenium WebDriver Documentation](https://www.selenium.dev/documentation/webdriver/)
|
|
255
|
+
- [RSpec Documentation](https://rspec.info/)
|
|
256
|
+
- [WebDrivers Gem](https://github.com/titusfortner/webdrivers)
|
data/README.md
CHANGED
|
@@ -105,9 +105,36 @@ params[:model][:content] # => Markdown text
|
|
|
105
105
|
|
|
106
106
|
This gem mainly provides Rails view elements (helpers, partials, JS/CSS assets), so UI and behavior cannot be automatically tested with standard RSpec, etc.
|
|
107
107
|
|
|
108
|
+
### E2Eテスト/E2E Testing
|
|
108
109
|
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
ChromeとFirefoxを使用したE2Eテストを用意しています。
|
|
111
|
+
|
|
112
|
+
E2E tests using Chrome and Firefox are available.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# すべてのテストを実行/Run all tests
|
|
116
|
+
bundle exec rspec spec/features/markdown_editor_e2e_spec.rb
|
|
117
|
+
|
|
118
|
+
# 簡単にテストを実行/Run tests easily
|
|
119
|
+
bin/run_e2e_tests
|
|
120
|
+
|
|
121
|
+
# 特定のブラウザでテスト/Test with specific browser
|
|
122
|
+
bin/run_e2e_tests --browser chrome
|
|
123
|
+
bin/run_e2e_tests --browser firefox
|
|
124
|
+
|
|
125
|
+
# 特定のカテゴリをテスト/Test specific category
|
|
126
|
+
bin/run_e2e_tests --category basic
|
|
127
|
+
bin/run_e2e_tests --category formatting
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
詳細は [E2E_TESTING.md](E2E_TESTING.md) を参照してください。
|
|
131
|
+
|
|
132
|
+
See [E2E_TESTING.md](E2E_TESTING.md) for more details.
|
|
133
|
+
|
|
134
|
+
### 手動テスト/Manual Testing
|
|
135
|
+
|
|
136
|
+
- UIやエディタの動作確認には、手動テストも推奨します。
|
|
137
|
+
- Manual testing is also recommended for UI/editor behavior verification.
|
|
111
138
|
|
|
112
139
|
## コントリビュート/Contributing
|
|
113
140
|
|
|
@@ -5,6 +5,59 @@
|
|
|
5
5
|
let _scanOffsetCache = new Map();
|
|
6
6
|
|
|
7
7
|
const analyzeHtmlCache = new Map();
|
|
8
|
+
const MAX_CACHE_SIZE = 500;
|
|
9
|
+
|
|
10
|
+
// Undo/Redo stack management
|
|
11
|
+
class UndoRedoManager {
|
|
12
|
+
constructor(maxStackSize = 100) {
|
|
13
|
+
this.undoStack = [];
|
|
14
|
+
this.redoStack = [];
|
|
15
|
+
this.maxStackSize = maxStackSize;
|
|
16
|
+
this.isUndoRedoAction = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
pushState(state) {
|
|
20
|
+
if (this.isUndoRedoAction) return;
|
|
21
|
+
|
|
22
|
+
this.undoStack.push(state);
|
|
23
|
+
if (this.undoStack.length > this.maxStackSize) {
|
|
24
|
+
this.undoStack.shift();
|
|
25
|
+
}
|
|
26
|
+
// Clear redo stack when new state is pushed
|
|
27
|
+
this.redoStack = [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
undo() {
|
|
31
|
+
if (this.undoStack.length === 0) return null;
|
|
32
|
+
|
|
33
|
+
const currentState = this.undoStack.pop();
|
|
34
|
+
this.redoStack.push(currentState);
|
|
35
|
+
|
|
36
|
+
return this.undoStack.length > 0 ? this.undoStack[this.undoStack.length - 1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
redo() {
|
|
40
|
+
if (this.redoStack.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
const state = this.redoStack.pop();
|
|
43
|
+
this.undoStack.push(state);
|
|
44
|
+
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
canUndo() {
|
|
49
|
+
return this.undoStack.length > 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
canRedo() {
|
|
53
|
+
return this.redoStack.length > 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
clear() {
|
|
57
|
+
this.undoStack = [];
|
|
58
|
+
this.redoStack = [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
8
61
|
|
|
9
62
|
// Debounce function for delayed execution
|
|
10
63
|
function debounce(func, wait) {
|
|
@@ -142,6 +195,80 @@
|
|
|
142
195
|
// 初期化時に同期
|
|
143
196
|
syncToHidden();
|
|
144
197
|
|
|
198
|
+
// inputイベントで同期
|
|
199
|
+
textarea.addEventListener('input', syncToHidden);
|
|
200
|
+
|
|
201
|
+
// Undo/Redo manager initialization
|
|
202
|
+
const undoRedoManager = new UndoRedoManager();
|
|
203
|
+
|
|
204
|
+
// Save editor state
|
|
205
|
+
function saveEditorState() {
|
|
206
|
+
const state = {
|
|
207
|
+
html: textarea.innerHTML,
|
|
208
|
+
text: textarea.innerText,
|
|
209
|
+
timestamp: Date.now()
|
|
210
|
+
};
|
|
211
|
+
undoRedoManager.pushState(state);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Restore editor state
|
|
215
|
+
function restoreEditorState(state) {
|
|
216
|
+
if (!state) return;
|
|
217
|
+
|
|
218
|
+
undoRedoManager.isUndoRedoAction = true;
|
|
219
|
+
textarea.innerHTML = state.html;
|
|
220
|
+
syncToHidden();
|
|
221
|
+
undoRedoManager.isUndoRedoAction = false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Perform undo
|
|
225
|
+
function performUndo() {
|
|
226
|
+
const previousState = undoRedoManager.undo();
|
|
227
|
+
restoreEditorState(previousState);
|
|
228
|
+
return undoRedoManager.canUndo();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Perform redo
|
|
232
|
+
function performRedo() {
|
|
233
|
+
const nextState = undoRedoManager.redo();
|
|
234
|
+
restoreEditorState(nextState);
|
|
235
|
+
return undoRedoManager.canRedo();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Save initial state
|
|
239
|
+
saveEditorState();
|
|
240
|
+
|
|
241
|
+
// Save state on input with debounce
|
|
242
|
+
const debouncedSaveState = debounce(saveEditorState, 500);
|
|
243
|
+
textarea.addEventListener('input', function(e) {
|
|
244
|
+
if (!undoRedoManager.isUndoRedoAction) {
|
|
245
|
+
debouncedSaveState();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Keyboard shortcuts for Undo/Redo
|
|
250
|
+
textarea.addEventListener('keydown', function(e) {
|
|
251
|
+
// Ctrl+Z or Cmd+Z for Undo
|
|
252
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
performUndo();
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Ctrl+Y or Cmd+Shift+Z for Redo
|
|
259
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
performRedo();
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Make undo/redo functions globally accessible
|
|
267
|
+
window.editorUndo = performUndo;
|
|
268
|
+
window.editorRedo = performRedo;
|
|
269
|
+
window.canUndo = () => undoRedoManager.canUndo();
|
|
270
|
+
window.canRedo = () => undoRedoManager.canRedo();
|
|
271
|
+
|
|
145
272
|
// フォーム送信時の処理を非同期化
|
|
146
273
|
const form = textarea.closest('form');
|
|
147
274
|
if (form) {
|
|
@@ -482,7 +609,7 @@
|
|
|
482
609
|
// 外部公開
|
|
483
610
|
window.getContentEditableSelection = getContentEditableSelection;
|
|
484
611
|
|
|
485
|
-
// Set selection range at specified position (
|
|
612
|
+
// Set selection range at specified position (改行を含むinnerTextベースの位置)
|
|
486
613
|
function setContentEditableSelection(element, start, end) {
|
|
487
614
|
element.focus();
|
|
488
615
|
|
|
@@ -492,78 +619,181 @@
|
|
|
492
619
|
start = Math.max(0, Math.min(start, fullText.length));
|
|
493
620
|
end = Math.max(start, Math.min(end, fullText.length));
|
|
494
621
|
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
null,
|
|
500
|
-
false
|
|
501
|
-
);
|
|
622
|
+
// buildLinearTextMapを使用してDOM構造を解析(改行を含む)
|
|
623
|
+
function buildLinearTextMap(container) {
|
|
624
|
+
let textMap = [];
|
|
625
|
+
let currentPos = 0;
|
|
502
626
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
627
|
+
function processNode(node) {
|
|
628
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
629
|
+
const text = node.textContent;
|
|
630
|
+
textMap.push({
|
|
631
|
+
node: node,
|
|
632
|
+
type: 'text',
|
|
633
|
+
start: currentPos,
|
|
634
|
+
end: currentPos + text.length,
|
|
635
|
+
text: text
|
|
636
|
+
});
|
|
637
|
+
currentPos += text.length;
|
|
638
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
639
|
+
if (node.tagName === 'DIV') {
|
|
640
|
+
if (node.children.length === 1 && node.children[0].tagName === 'BR') {
|
|
641
|
+
textMap.push({
|
|
642
|
+
node: node,
|
|
643
|
+
type: 'div-br',
|
|
644
|
+
start: currentPos,
|
|
645
|
+
end: currentPos + 1,
|
|
646
|
+
text: '\n'
|
|
647
|
+
});
|
|
648
|
+
currentPos += 1;
|
|
649
|
+
return;
|
|
650
|
+
} else if (node.innerHTML === '<br>' || node.innerHTML === '<br/>' || node.innerHTML === '') {
|
|
651
|
+
textMap.push({
|
|
652
|
+
node: node,
|
|
653
|
+
type: 'empty-div',
|
|
654
|
+
start: currentPos,
|
|
655
|
+
end: currentPos + 1,
|
|
656
|
+
text: '\n'
|
|
657
|
+
});
|
|
658
|
+
currentPos += 1;
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
} else if (node.tagName === 'BR') {
|
|
662
|
+
textMap.push({
|
|
663
|
+
node: node,
|
|
664
|
+
type: 'br',
|
|
665
|
+
start: currentPos,
|
|
666
|
+
end: currentPos + 1,
|
|
667
|
+
text: '\n'
|
|
668
|
+
});
|
|
669
|
+
currentPos += 1;
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
508
672
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
673
|
+
// 子ノードを処理
|
|
674
|
+
for (let child of node.childNodes) {
|
|
675
|
+
processNode(child);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
513
679
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
element,
|
|
517
|
-
NodeFilter.SHOW_TEXT,
|
|
518
|
-
null,
|
|
519
|
-
false
|
|
520
|
-
);
|
|
521
|
-
while (node = newWalker.nextNode()) {
|
|
522
|
-
textNodes.push(node);
|
|
680
|
+
for (let child of container.childNodes) {
|
|
681
|
+
processNode(child);
|
|
523
682
|
}
|
|
683
|
+
|
|
684
|
+
return textMap;
|
|
524
685
|
}
|
|
525
686
|
|
|
526
|
-
|
|
527
|
-
|
|
687
|
+
const textMap = buildLinearTextMap(element);
|
|
688
|
+
|
|
689
|
+
// startとendの位置に対応するノードとオフセットを見つける
|
|
528
690
|
let startNode = null, startOffset = 0;
|
|
529
691
|
let endNode = null, endOffset = 0;
|
|
530
692
|
|
|
531
|
-
for (let
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
693
|
+
for (let item of textMap) {
|
|
694
|
+
if (!startNode && start >= item.start && start < item.end) {
|
|
695
|
+
// Position is within this item
|
|
696
|
+
if (item.type === 'text') {
|
|
697
|
+
startNode = item.node;
|
|
698
|
+
startOffset = start - item.start;
|
|
699
|
+
} else {
|
|
700
|
+
// Position is at the start of a line break element
|
|
701
|
+
// Set cursor before this element (not after)
|
|
702
|
+
const parent = item.node.parentNode;
|
|
703
|
+
let childIndex = 0;
|
|
704
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
705
|
+
if (parent.childNodes[i] === item.node) {
|
|
706
|
+
childIndex = i; // Position before this node
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
startNode = parent;
|
|
711
|
+
startOffset = childIndex;
|
|
712
|
+
}
|
|
713
|
+
} else if (!startNode && start === item.end) {
|
|
714
|
+
// Position is at the end boundary of this item
|
|
715
|
+
if (item.type === 'text') {
|
|
716
|
+
// At end of text node
|
|
717
|
+
startNode = item.node;
|
|
718
|
+
startOffset = start - item.start;
|
|
719
|
+
} else {
|
|
720
|
+
// At end of line break element - set cursor after it
|
|
721
|
+
const parent = item.node.parentNode;
|
|
722
|
+
let childIndex = 0;
|
|
723
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
724
|
+
if (parent.childNodes[i] === item.node) {
|
|
725
|
+
childIndex = i + 1; // Position after this node
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
startNode = parent;
|
|
730
|
+
startOffset = childIndex;
|
|
731
|
+
}
|
|
539
732
|
}
|
|
540
733
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
734
|
+
if (!endNode && end >= item.start && end < item.end) {
|
|
735
|
+
if (item.type === 'text') {
|
|
736
|
+
endNode = item.node;
|
|
737
|
+
endOffset = end - item.start;
|
|
738
|
+
} else {
|
|
739
|
+
// Position is at the start of a line break element
|
|
740
|
+
const parent = item.node.parentNode;
|
|
741
|
+
let childIndex = 0;
|
|
742
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
743
|
+
if (parent.childNodes[i] === item.node) {
|
|
744
|
+
childIndex = i; // Position before this node
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
endNode = parent;
|
|
749
|
+
endOffset = childIndex;
|
|
750
|
+
}
|
|
751
|
+
} else if (!endNode && end === item.end) {
|
|
752
|
+
if (item.type === 'text') {
|
|
753
|
+
endNode = item.node;
|
|
754
|
+
endOffset = end - item.start;
|
|
755
|
+
} else {
|
|
756
|
+
const parent = item.node.parentNode;
|
|
757
|
+
let childIndex = 0;
|
|
758
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
759
|
+
if (parent.childNodes[i] === item.node) {
|
|
760
|
+
childIndex = i + 1;
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
endNode = parent;
|
|
765
|
+
endOffset = childIndex;
|
|
766
|
+
}
|
|
546
767
|
}
|
|
547
768
|
|
|
548
|
-
|
|
769
|
+
if (startNode && endNode) break;
|
|
549
770
|
}
|
|
550
771
|
|
|
551
|
-
//
|
|
552
|
-
if (!startNode
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
772
|
+
// Fallback: 位置が見つからない場合、最後のテキストノードの末尾に設定
|
|
773
|
+
if (!startNode || !endNode) {
|
|
774
|
+
const textItems = textMap.filter(i => i.type === 'text');
|
|
775
|
+
if (textItems.length > 0) {
|
|
776
|
+
const lastTextItem = textItems[textItems.length - 1];
|
|
777
|
+
if (!startNode) {
|
|
778
|
+
startNode = lastTextItem.node;
|
|
779
|
+
startOffset = lastTextItem.node.textContent.length;
|
|
780
|
+
}
|
|
781
|
+
if (!endNode) {
|
|
782
|
+
endNode = lastTextItem.node;
|
|
783
|
+
endOffset = lastTextItem.node.textContent.length;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
560
786
|
}
|
|
561
787
|
|
|
562
788
|
if (startNode && endNode) {
|
|
563
789
|
try {
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
790
|
+
// Only clamp offset for text nodes, not element nodes
|
|
791
|
+
if (startNode.nodeType === Node.TEXT_NODE) {
|
|
792
|
+
startOffset = Math.max(0, Math.min(startOffset, startNode.textContent.length));
|
|
793
|
+
}
|
|
794
|
+
if (endNode.nodeType === Node.TEXT_NODE) {
|
|
795
|
+
endOffset = Math.max(0, Math.min(endOffset, endNode.textContent.length));
|
|
796
|
+
}
|
|
567
797
|
|
|
568
798
|
const range = document.createRange();
|
|
569
799
|
range.setStart(startNode, startOffset);
|
|
@@ -575,7 +805,6 @@
|
|
|
575
805
|
|
|
576
806
|
} catch (e) {
|
|
577
807
|
console.error('Selection setting failed:', e);
|
|
578
|
-
// Final fallback
|
|
579
808
|
try {
|
|
580
809
|
const range = document.createRange();
|
|
581
810
|
range.selectNodeContents(element);
|
|
@@ -730,44 +959,97 @@
|
|
|
730
959
|
}
|
|
731
960
|
}
|
|
732
961
|
|
|
962
|
+
// ノードがtextMapにない場合(親コンテナなど)、子ノードから計算
|
|
963
|
+
if (node === container && node.nodeType === Node.ELEMENT_NODE) {
|
|
964
|
+
let calculatedOffset = 0;
|
|
965
|
+
for (let i = 0; i < offset && i < node.childNodes.length; i++) {
|
|
966
|
+
const child = node.childNodes[i];
|
|
967
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
968
|
+
calculatedOffset += child.textContent.length;
|
|
969
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
970
|
+
// Check if this child is in the textMap
|
|
971
|
+
const childItem = textMap.find(item => item.node === child);
|
|
972
|
+
if (childItem) {
|
|
973
|
+
calculatedOffset += (childItem.end - childItem.start);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return calculatedOffset;
|
|
978
|
+
}
|
|
979
|
+
|
|
733
980
|
return 0;
|
|
734
981
|
}
|
|
735
982
|
|
|
736
983
|
function selectLineNumberAndCharIndex(beginEndLenStrings, beginCharIndex, endCharIndex) {
|
|
737
984
|
let retBeginLine = 0, retBeginCharIndex = 0;
|
|
738
985
|
let retEndLine = 0, retEndCharIndex = 0;
|
|
739
|
-
let emptyLineCount = 0;
|
|
740
986
|
|
|
987
|
+
// Find line for beginCharIndex - handle boundary ambiguity by preferring non-empty lines
|
|
988
|
+
let beginMatches = [];
|
|
741
989
|
for (let i = 0; i < beginEndLenStrings.length; i++) {
|
|
742
990
|
const line = beginEndLenStrings[i];
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
991
|
+
if (beginCharIndex >= line.begin && beginCharIndex <= line.end) {
|
|
992
|
+
beginMatches.push({
|
|
993
|
+
line: i,
|
|
994
|
+
char: beginCharIndex - line.begin,
|
|
995
|
+
isEmpty: line.str === "⹉",
|
|
996
|
+
isAtBoundary: beginCharIndex === line.begin || beginCharIndex === line.end
|
|
997
|
+
});
|
|
746
998
|
}
|
|
999
|
+
}
|
|
747
1000
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
1001
|
+
console.log('selectLineNumberAndCharIndex DEBUG:');
|
|
1002
|
+
console.log(' beginCharIndex:', beginCharIndex);
|
|
1003
|
+
console.log(' beginMatches:', JSON.stringify(beginMatches, null, 2));
|
|
1004
|
+
|
|
1005
|
+
// Save to debug info for testing
|
|
1006
|
+
if (typeof window !== 'undefined' && window._debugInfo) {
|
|
1007
|
+
window._debugInfo.selectDebug = {
|
|
1008
|
+
beginCharIndex: beginCharIndex,
|
|
1009
|
+
beginMatches: beginMatches
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// If multiple matches, prefer non-empty lines
|
|
1014
|
+
if (beginMatches.length > 1) {
|
|
1015
|
+
const nonEmptyMatches = beginMatches.filter(m => !m.isEmpty);
|
|
1016
|
+
if (nonEmptyMatches.length > 0) {
|
|
1017
|
+
beginMatches = nonEmptyMatches;
|
|
752
1018
|
}
|
|
753
1019
|
}
|
|
754
1020
|
|
|
755
|
-
|
|
1021
|
+
if (beginMatches.length > 0) {
|
|
1022
|
+
retBeginLine = beginMatches[0].line;
|
|
1023
|
+
retBeginCharIndex = beginMatches[0].char;
|
|
1024
|
+
}
|
|
756
1025
|
|
|
1026
|
+
// Find line for endCharIndex - handle boundary ambiguity by preferring non-empty lines
|
|
1027
|
+
let endMatches = [];
|
|
757
1028
|
for (let i = 0; i < beginEndLenStrings.length; i++) {
|
|
758
1029
|
const line = beginEndLenStrings[i];
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1030
|
+
if (endCharIndex >= line.begin && endCharIndex <= line.end) {
|
|
1031
|
+
endMatches.push({
|
|
1032
|
+
line: i,
|
|
1033
|
+
char: endCharIndex - line.begin,
|
|
1034
|
+
isEmpty: line.str === "⹉",
|
|
1035
|
+
isAtBoundary: endCharIndex === line.begin || endCharIndex === line.end
|
|
1036
|
+
});
|
|
762
1037
|
}
|
|
1038
|
+
}
|
|
763
1039
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1040
|
+
// If multiple matches, prefer non-empty lines
|
|
1041
|
+
if (endMatches.length > 1) {
|
|
1042
|
+
const nonEmptyMatches = endMatches.filter(m => !m.isEmpty);
|
|
1043
|
+
if (nonEmptyMatches.length > 0) {
|
|
1044
|
+
endMatches = nonEmptyMatches;
|
|
768
1045
|
}
|
|
769
1046
|
}
|
|
770
1047
|
|
|
1048
|
+
if (endMatches.length > 0) {
|
|
1049
|
+
retEndLine = endMatches[0].line;
|
|
1050
|
+
retEndCharIndex = endMatches[0].char;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
771
1053
|
return { begin: { line: retBeginLine, char: retBeginCharIndex }, end: { line: retEndLine, char: retEndCharIndex } };
|
|
772
1054
|
}
|
|
773
1055
|
|
|
@@ -776,9 +1058,15 @@
|
|
|
776
1058
|
for (let i = 0; i < beginEndLenStrings.length; i++) {
|
|
777
1059
|
const line = beginEndLenStrings[i];
|
|
778
1060
|
|
|
779
|
-
//
|
|
1061
|
+
// 空行(改行行)の処理
|
|
780
1062
|
if (line.str === "⹉") {
|
|
781
|
-
|
|
1063
|
+
// カーソルが空行にある場合、before + after を挿入
|
|
1064
|
+
if (i === targetTextPosition.begin.line && i === targetTextPosition.end.line) {
|
|
1065
|
+
newLines.push(before + after);
|
|
1066
|
+
} else {
|
|
1067
|
+
// 空行は変更せずそのまま保持
|
|
1068
|
+
newLines.push(line.str);
|
|
1069
|
+
}
|
|
782
1070
|
continue;
|
|
783
1071
|
}
|
|
784
1072
|
|
|
@@ -825,18 +1113,21 @@
|
|
|
825
1113
|
}
|
|
826
1114
|
|
|
827
1115
|
function convertToInnerHtml(newLines) {
|
|
1116
|
+
// ブラウザのネイティブ構造に合わせて、<br>タグを使用した平坦な構造を生成
|
|
828
1117
|
let html = "";
|
|
829
1118
|
for (let i = 0; i < newLines.length; i++) {
|
|
830
|
-
|
|
831
|
-
|
|
1119
|
+
let insert = newLines[i];
|
|
1120
|
+
if (i > 0) {
|
|
1121
|
+
// 行の区切りとして<br>を追加
|
|
1122
|
+
html += "<br>";
|
|
1123
|
+
}
|
|
1124
|
+
if (insert === "⹉") {
|
|
1125
|
+
// 空行の場合は何も追加しない(<br>タグのみで表現)
|
|
1126
|
+
// noop
|
|
832
1127
|
} else {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
} else {
|
|
837
|
-
insert = insert.split("⹉").join("");
|
|
838
|
-
}
|
|
839
|
-
html += "<div>" + insert + "</div>";
|
|
1128
|
+
// 通常の行の場合はテキストを追加
|
|
1129
|
+
insert = insert.split("⹉").join("");
|
|
1130
|
+
html += insert;
|
|
840
1131
|
}
|
|
841
1132
|
}
|
|
842
1133
|
return html;
|
|
@@ -908,8 +1199,6 @@
|
|
|
908
1199
|
}
|
|
909
1200
|
}
|
|
910
1201
|
|
|
911
|
-
const MAX_CACHE_SIZE = 500;
|
|
912
|
-
|
|
913
1202
|
function analyzeHtml(target, isCountEmptyDiv = false) {
|
|
914
1203
|
|
|
915
1204
|
const cacheKey = `${target}_${isCountEmptyDiv}`;
|
|
@@ -918,68 +1207,121 @@
|
|
|
918
1207
|
return analyzeHtmlCache.get(cacheKey);
|
|
919
1208
|
}
|
|
920
1209
|
|
|
1210
|
+
// Helper function to strip HTML tags
|
|
1211
|
+
function stripHtmlTags(html) {
|
|
1212
|
+
return html.replace(/<[^>]*>/g, '');
|
|
1213
|
+
}
|
|
1214
|
+
|
|
921
1215
|
let lines = [];
|
|
922
|
-
let remain = target;
|
|
923
1216
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
if (remain.startsWith("<div><br></div>")) {
|
|
927
|
-
lines.push("⹉");
|
|
928
|
-
remain = remain.substring(15);
|
|
929
|
-
continue;
|
|
930
|
-
}
|
|
1217
|
+
// Check if this is div-based structure or br-based structure
|
|
1218
|
+
const hasDivStructure = target.startsWith("<div>") || target.startsWith("<div ");
|
|
931
1219
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1220
|
+
if (!hasDivStructure && target.includes("<br>")) {
|
|
1221
|
+
// Br-based structure (flat): Split by <br> tags
|
|
1222
|
+
const segments = target.split("<br>");
|
|
1223
|
+
for (let segment of segments) {
|
|
1224
|
+
const textContent = stripHtmlTags(segment);
|
|
1225
|
+
if (textContent === "") {
|
|
935
1226
|
lines.push("⹉");
|
|
1227
|
+
} else {
|
|
1228
|
+
lines.push(textContent);
|
|
936
1229
|
}
|
|
937
|
-
remain = remain.substring(11);
|
|
938
|
-
continue;
|
|
939
1230
|
}
|
|
1231
|
+
} else {
|
|
1232
|
+
// Div-based structure: Parse divs
|
|
1233
|
+
let remain = target;
|
|
940
1234
|
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1235
|
+
while (remain.length > 0) {
|
|
1236
|
+
// <div><br></div> パターン(改行)- 常に維持
|
|
1237
|
+
if (remain.startsWith("<div><br></div>")) {
|
|
1238
|
+
lines.push("⹉");
|
|
1239
|
+
remain = remain.substring(15);
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
948
1242
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1243
|
+
// <div></div> パターン(空のdiv)- isCountEmptyDivで制御
|
|
1244
|
+
if (remain.startsWith("<div></div>")) {
|
|
1245
|
+
if (isCountEmptyDiv) {
|
|
1246
|
+
lines.push("⹉");
|
|
1247
|
+
}
|
|
1248
|
+
remain = remain.substring(11);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
955
1251
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1252
|
+
// <div>内容</div> パターン(ネストされたタグも考慮)
|
|
1253
|
+
if (remain.startsWith("<div>")) {
|
|
1254
|
+
// Find the matching </div> by counting depth
|
|
1255
|
+
let depth = 0;
|
|
1256
|
+
let i = 0;
|
|
1257
|
+
let foundMatch = false;
|
|
1258
|
+
|
|
1259
|
+
while (i < remain.length) {
|
|
1260
|
+
if (remain.substring(i, i + 5) === "<div>") {
|
|
1261
|
+
depth++;
|
|
1262
|
+
i += 5;
|
|
1263
|
+
} else if (remain.substring(i, i + 6) === "</div>") {
|
|
1264
|
+
depth--;
|
|
1265
|
+
if (depth === 0) {
|
|
1266
|
+
// Found the matching closing tag
|
|
1267
|
+
const content = remain.substring(5, i); // Content between <div> and </div>
|
|
1268
|
+
// Strip all HTML tags from content to get text only
|
|
1269
|
+
const textContent = stripHtmlTags(content);
|
|
1270
|
+
lines.push(textContent);
|
|
1271
|
+
remain = remain.substring(i + 6);
|
|
1272
|
+
foundMatch = true;
|
|
1273
|
+
break;
|
|
1274
|
+
}
|
|
1275
|
+
i += 6;
|
|
1276
|
+
} else {
|
|
1277
|
+
i++;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
963
1280
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
if (remain.trim()) {
|
|
968
|
-
lines.push(remain);
|
|
1281
|
+
if (foundMatch) {
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
969
1284
|
}
|
|
970
|
-
break;
|
|
971
|
-
}
|
|
972
1285
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1286
|
+
// <div> の開始を探す
|
|
1287
|
+
const divIndex = remain.indexOf("<div>");
|
|
1288
|
+
if (divIndex > 0) {
|
|
1289
|
+
// Strip HTML tags from the text before <div>
|
|
1290
|
+
const textContent = stripHtmlTags(remain.substring(0, divIndex));
|
|
1291
|
+
if (textContent) {
|
|
1292
|
+
lines.push(textContent);
|
|
1293
|
+
}
|
|
1294
|
+
remain = remain.substring(divIndex);
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// その他のタグまたは残りのテキスト
|
|
1299
|
+
const nextTagIndex = remain.indexOf("<");
|
|
1300
|
+
if (nextTagIndex === -1) {
|
|
1301
|
+
// No more tags, strip any remaining HTML and add as text
|
|
1302
|
+
const textContent = stripHtmlTags(remain);
|
|
1303
|
+
if (textContent.trim()) {
|
|
1304
|
+
lines.push(textContent);
|
|
1305
|
+
}
|
|
981
1306
|
break;
|
|
982
1307
|
}
|
|
1308
|
+
|
|
1309
|
+
if (nextTagIndex > 0) {
|
|
1310
|
+
// Text before next tag
|
|
1311
|
+
const textContent = stripHtmlTags(remain.substring(0, nextTagIndex));
|
|
1312
|
+
if (textContent) {
|
|
1313
|
+
lines.push(textContent);
|
|
1314
|
+
}
|
|
1315
|
+
remain = remain.substring(nextTagIndex);
|
|
1316
|
+
} else {
|
|
1317
|
+
// Skip the tag
|
|
1318
|
+
const tagEnd = remain.indexOf(">");
|
|
1319
|
+
if (tagEnd !== -1) {
|
|
1320
|
+
remain = remain.substring(tagEnd + 1);
|
|
1321
|
+
} else {
|
|
1322
|
+
break;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
983
1325
|
}
|
|
984
1326
|
}
|
|
985
1327
|
|
|
@@ -1010,8 +1352,14 @@
|
|
|
1010
1352
|
const isContentEditable = textarea.contentEditable === 'true' ||
|
|
1011
1353
|
textarea.getAttribute('contenteditable') === 'true';
|
|
1012
1354
|
|
|
1355
|
+
console.log('=== HTML Debug ===');
|
|
1356
|
+
console.log('textarea.innerHTML:', textarea.innerHTML);
|
|
1357
|
+
|
|
1013
1358
|
let lines = analyzeHtml(textarea.innerHTML.replace('\n \n ', ''));
|
|
1014
1359
|
|
|
1360
|
+
console.log('lines from analyzeHtml:', JSON.stringify(lines, null, 2));
|
|
1361
|
+
console.log('==================');
|
|
1362
|
+
|
|
1015
1363
|
// === 修正部分:DOM構造に基づいたテキスト表現を構築 ===
|
|
1016
1364
|
let domBasedText = '';
|
|
1017
1365
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1023,6 +1371,9 @@
|
|
|
1023
1371
|
}
|
|
1024
1372
|
|
|
1025
1373
|
// beginEndLenStringsをDOM構造ベースで構築
|
|
1374
|
+
// br-based構造かdiv-based構造かを判定
|
|
1375
|
+
const isBrBased = !textarea.innerHTML.startsWith("<div>") && !textarea.innerHTML.startsWith("<div ") && textarea.innerHTML.includes("<br>");
|
|
1376
|
+
|
|
1026
1377
|
let offset = 0;
|
|
1027
1378
|
let beginEndLenStrings = [];
|
|
1028
1379
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1030,7 +1381,9 @@
|
|
|
1030
1381
|
let actualLength;
|
|
1031
1382
|
|
|
1032
1383
|
if (line === "⹉") {
|
|
1033
|
-
|
|
1384
|
+
// br-basedの場合、空行は長さ0(<br>が改行を表現している)
|
|
1385
|
+
// div-basedの場合は長さ1
|
|
1386
|
+
actualLength = isBrBased ? 0 : 1;
|
|
1034
1387
|
} else {
|
|
1035
1388
|
actualLength = line.length;
|
|
1036
1389
|
}
|
|
@@ -1042,6 +1395,11 @@
|
|
|
1042
1395
|
str: line,
|
|
1043
1396
|
});
|
|
1044
1397
|
offset += actualLength;
|
|
1398
|
+
|
|
1399
|
+
// br-based構造の場合、各行(最後以外)の後に<br>セパレータがあるので+1
|
|
1400
|
+
if (isBrBased && i < lines.length - 1) {
|
|
1401
|
+
offset += 1; // <br>セパレータ分
|
|
1402
|
+
}
|
|
1045
1403
|
}
|
|
1046
1404
|
// ===================================================
|
|
1047
1405
|
|
|
@@ -1063,10 +1421,9 @@
|
|
|
1063
1421
|
let selectLineMode = false;
|
|
1064
1422
|
let selectedLinePos;
|
|
1065
1423
|
|
|
1066
|
-
if (!selection.rangeCount
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
selectedLinePos = getCurrentLineIndex(beginEndLenStrings);
|
|
1424
|
+
if (!selection.rangeCount) {
|
|
1425
|
+
console.warn('No selection range');
|
|
1426
|
+
return;
|
|
1070
1427
|
}
|
|
1071
1428
|
|
|
1072
1429
|
const range = selection.getRangeAt(0);
|
|
@@ -1079,11 +1436,19 @@
|
|
|
1079
1436
|
const startOffset = getOffsetInContainer(textarea, range.startContainer, range.startOffset);
|
|
1080
1437
|
const endOffset = getOffsetInContainer(textarea, range.endContainer, range.endOffset);
|
|
1081
1438
|
|
|
1082
|
-
// ===
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1439
|
+
// === デバッグ出力 ===
|
|
1440
|
+
console.log('=== insertMarkdown Debug ===');
|
|
1441
|
+
console.log('DOM-based text:', JSON.stringify(domBasedText));
|
|
1442
|
+
console.log('Selection offsets - start:', startOffset, 'end:', endOffset);
|
|
1443
|
+
console.log('Selection containers - start:', range.startContainer, 'end:', range.endContainer);
|
|
1444
|
+
console.log('Inserted text will be:', before + after);
|
|
1445
|
+
|
|
1446
|
+
// Save debug info to DOM for testing
|
|
1447
|
+
window._debugInfo = {
|
|
1448
|
+
domBasedText: domBasedText,
|
|
1449
|
+
startOffset: startOffset,
|
|
1450
|
+
endOffset: endOffset
|
|
1451
|
+
};
|
|
1087
1452
|
// =============================
|
|
1088
1453
|
|
|
1089
1454
|
const startPos = getLineAndCharIndex(textarea, startOffset);
|
|
@@ -1091,65 +1456,54 @@
|
|
|
1091
1456
|
|
|
1092
1457
|
const selectedTextContent = selection.toString();
|
|
1093
1458
|
|
|
1459
|
+
console.log('=== Line Position Debug ===');
|
|
1460
|
+
console.log('beginEndLenStrings:', JSON.stringify(beginEndLenStrings, null, 2));
|
|
1461
|
+
console.log('startOffset:', startOffset, 'endOffset:', endOffset);
|
|
1462
|
+
console.log('selectedTextContent:', JSON.stringify(selectedTextContent));
|
|
1463
|
+
console.log('selectLineMode:', selectLineMode);
|
|
1464
|
+
|
|
1094
1465
|
const targetTextPosition = selectLineNumberAndCharIndex(beginEndLenStrings, startOffset, endOffset);
|
|
1095
1466
|
|
|
1467
|
+
console.log('targetTextPosition:', JSON.stringify(targetTextPosition, null, 2));
|
|
1468
|
+
|
|
1096
1469
|
const newLines = !selectLineMode
|
|
1097
1470
|
? replaceLineNumberAndCharIndex(beginEndLenStrings, targetTextPosition, before, after)
|
|
1098
1471
|
: replaceLine(beginEndLenStrings, selectedLinePos.line, before, after);
|
|
1099
1472
|
|
|
1473
|
+
console.log('newLines:', JSON.stringify(newLines, null, 2));
|
|
1474
|
+
console.log('newLines.length:', newLines.length);
|
|
1475
|
+
console.log('newLines breakdown:');
|
|
1476
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
1477
|
+
console.log(` Line ${i}: ${JSON.stringify(newLines[i])}`);
|
|
1478
|
+
}
|
|
1479
|
+
console.log('==========================');
|
|
1480
|
+
|
|
1481
|
+
// Save more debug info
|
|
1482
|
+
window._debugInfo.beginEndLenStrings = beginEndLenStrings;
|
|
1483
|
+
window._debugInfo.targetTextPosition = targetTextPosition;
|
|
1484
|
+
window._debugInfo.newLines = newLines;
|
|
1485
|
+
|
|
1100
1486
|
const newFullHTML = convertToInnerHtml(newLines);
|
|
1101
1487
|
|
|
1102
1488
|
if (isContentEditable) {
|
|
1103
1489
|
textarea.innerHTML = newFullHTML;
|
|
1104
1490
|
textarea.focus();
|
|
1105
1491
|
|
|
1106
|
-
//
|
|
1107
|
-
const
|
|
1108
|
-
(before + selectedTextContent + after) :
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
// 新しく挿入されたテキストの終端を探す
|
|
1112
|
-
const walker = document.createTreeWalker(
|
|
1113
|
-
textarea,
|
|
1114
|
-
NodeFilter.SHOW_TEXT,
|
|
1115
|
-
null,
|
|
1116
|
-
false
|
|
1117
|
-
);
|
|
1118
|
-
|
|
1119
|
-
let foundNode = null;
|
|
1120
|
-
let foundOffset = 0;
|
|
1121
|
-
let node;
|
|
1122
|
-
|
|
1123
|
-
while (node = walker.nextNode()) {
|
|
1124
|
-
if (node.textContent.includes(insertedText)) {
|
|
1125
|
-
// 挿入されたテキストを含むノードを発見
|
|
1126
|
-
const textIndex = node.textContent.indexOf(insertedText);
|
|
1127
|
-
if (textIndex !== -1) {
|
|
1128
|
-
foundNode = node;
|
|
1129
|
-
foundOffset = textIndex + insertedText.length;
|
|
1130
|
-
break;
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1492
|
+
// カーソル位置を計算(空文字選択時はbeforeの直後、テキスト選択時は挿入テキストの最後)
|
|
1493
|
+
const cursorOffsetInInsertedText = selectedTextContent.length > 0 ?
|
|
1494
|
+
(before.length + selectedTextContent.length + after.length) :
|
|
1495
|
+
before.length;
|
|
1134
1496
|
|
|
1135
|
-
|
|
1136
|
-
// 見つかったテキストノード内にカーソルを設定
|
|
1137
|
-
const range = document.createRange();
|
|
1138
|
-
range.setStart(foundNode, Math.min(foundOffset, foundNode.textContent.length));
|
|
1139
|
-
range.collapse(true);
|
|
1497
|
+
const targetPosition = startOffset + cursorOffsetInInsertedText;
|
|
1140
1498
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
newDomText.length
|
|
1150
|
-
);
|
|
1151
|
-
setContentEditableSelection(textarea, targetPosition, targetPosition);
|
|
1152
|
-
}
|
|
1499
|
+
console.log('=== Cursor Position Debug ===');
|
|
1500
|
+
console.log('startOffset:', startOffset);
|
|
1501
|
+
console.log('cursorOffsetInInsertedText:', cursorOffsetInInsertedText);
|
|
1502
|
+
console.log('targetPosition:', targetPosition);
|
|
1503
|
+
console.log('============================');
|
|
1504
|
+
|
|
1505
|
+
// setContentEditableSelection関数を使用(innerTextベースで改行を正しく扱う)
|
|
1506
|
+
setContentEditableSelection(textarea, targetPosition, targetPosition);
|
|
1153
1507
|
}
|
|
1154
1508
|
|
|
1155
1509
|
// Fire input event
|
|
@@ -1160,6 +1514,10 @@
|
|
|
1160
1514
|
}
|
|
1161
1515
|
};
|
|
1162
1516
|
|
|
1517
|
+
// Expose setContentEditableSelection for testing
|
|
1518
|
+
window.setContentEditableSelection = setContentEditableSelection;
|
|
1519
|
+
window._debugGetOffsetInContainer = getOffsetInContainer;
|
|
1520
|
+
|
|
1163
1521
|
function buildDomBasedText(element) {
|
|
1164
1522
|
const lines = analyzeHtml(element.innerHTML);
|
|
1165
1523
|
let domText = '';
|
|
@@ -28,7 +28,8 @@ module PonkotsuMdEditor
|
|
|
28
28
|
attribute = attribute[:attribute] if attribute.is_a?(Hash)
|
|
29
29
|
content = content[:attribute] if content.is_a?(Hash)
|
|
30
30
|
options = options[:options] if options.is_a?(Hash)
|
|
31
|
-
render "ponkotsu_md_editor/editor",
|
|
31
|
+
render "ponkotsu_md_editor/editor",
|
|
32
|
+
locals: { attribute: attribute, content: content, form: form, options: options }
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ponkotsu-md-editor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dhq_boiler
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 8.
|
|
18
|
+
version: '8.1'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 8.
|
|
25
|
+
version: '8.1'
|
|
26
26
|
description: There is a bug in Chrome where entering large amounts of text into a
|
|
27
27
|
textarea element causes significant slowness (https://issues.chromium.org/issues/341564372).
|
|
28
28
|
This gem serves as a countermeasure for that issue.
|
|
@@ -32,7 +32,9 @@ executables: []
|
|
|
32
32
|
extensions: []
|
|
33
33
|
extra_rdoc_files: []
|
|
34
34
|
files:
|
|
35
|
+
- ".ruby-version"
|
|
35
36
|
- CHANGELOG.md
|
|
37
|
+
- E2E_TESTING.md
|
|
36
38
|
- LICENSE.txt
|
|
37
39
|
- README.md
|
|
38
40
|
- Rakefile
|
|
@@ -64,7 +66,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
64
66
|
requirements:
|
|
65
67
|
- - ">="
|
|
66
68
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: 3.
|
|
69
|
+
version: 3.0.0
|
|
68
70
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
71
|
requirements:
|
|
70
72
|
- - ">="
|