inkpen 0.7.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 +7 -0
- data/.DS_Store +0 -0
- data/.rubocop.yml +8 -0
- data/.yardopts +11 -0
- data/CLAUDE.md +141 -0
- data/README.md +409 -0
- data/Rakefile +19 -0
- data/app/assets/javascripts/inkpen/controllers/editor_controller.js +2050 -0
- data/app/assets/javascripts/inkpen/controllers/sticky_toolbar_controller.js +667 -0
- data/app/assets/javascripts/inkpen/controllers/toolbar_controller.js +693 -0
- data/app/assets/javascripts/inkpen/export/html.js +637 -0
- data/app/assets/javascripts/inkpen/export/index.js +30 -0
- data/app/assets/javascripts/inkpen/export/markdown.js +697 -0
- data/app/assets/javascripts/inkpen/export/pdf.js +372 -0
- data/app/assets/javascripts/inkpen/extensions/advanced_table.js +640 -0
- data/app/assets/javascripts/inkpen/extensions/block_commands.js +300 -0
- data/app/assets/javascripts/inkpen/extensions/block_gutter.js +338 -0
- data/app/assets/javascripts/inkpen/extensions/callout.js +303 -0
- data/app/assets/javascripts/inkpen/extensions/columns.js +403 -0
- data/app/assets/javascripts/inkpen/extensions/database.js +990 -0
- data/app/assets/javascripts/inkpen/extensions/document_section.js +352 -0
- data/app/assets/javascripts/inkpen/extensions/drag_handle.js +407 -0
- data/app/assets/javascripts/inkpen/extensions/embed.js +629 -0
- data/app/assets/javascripts/inkpen/extensions/enhanced_image.js +566 -0
- data/app/assets/javascripts/inkpen/extensions/export_commands.js +271 -0
- data/app/assets/javascripts/inkpen/extensions/file_attachment.js +593 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/index.js +58 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table.js +638 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_cell.js +100 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_header.js +100 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_constants.js +152 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_helpers.js +254 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_menu.js +282 -0
- data/app/assets/javascripts/inkpen/extensions/preformatted.js +239 -0
- data/app/assets/javascripts/inkpen/extensions/section.js +281 -0
- data/app/assets/javascripts/inkpen/extensions/section_title.js +126 -0
- data/app/assets/javascripts/inkpen/extensions/slash_commands.js +439 -0
- data/app/assets/javascripts/inkpen/extensions/table_of_contents.js +474 -0
- data/app/assets/javascripts/inkpen/extensions/toggle_block.js +332 -0
- data/app/assets/javascripts/inkpen/index.js +87 -0
- data/app/assets/stylesheets/inkpen/advanced_table.css +514 -0
- data/app/assets/stylesheets/inkpen/animations.css +626 -0
- data/app/assets/stylesheets/inkpen/block_gutter.css +265 -0
- data/app/assets/stylesheets/inkpen/callout.css +359 -0
- data/app/assets/stylesheets/inkpen/columns.css +314 -0
- data/app/assets/stylesheets/inkpen/database.css +658 -0
- data/app/assets/stylesheets/inkpen/document_section.css +305 -0
- data/app/assets/stylesheets/inkpen/drag_drop.css +220 -0
- data/app/assets/stylesheets/inkpen/editor.css +652 -0
- data/app/assets/stylesheets/inkpen/embed.css +468 -0
- data/app/assets/stylesheets/inkpen/enhanced_image.css +453 -0
- data/app/assets/stylesheets/inkpen/export.css +499 -0
- data/app/assets/stylesheets/inkpen/file_attachment.css +347 -0
- data/app/assets/stylesheets/inkpen/footnotes.css +136 -0
- data/app/assets/stylesheets/inkpen/inkpen_table.css +608 -0
- data/app/assets/stylesheets/inkpen/preformatted.css +215 -0
- data/app/assets/stylesheets/inkpen/search_replace.css +58 -0
- data/app/assets/stylesheets/inkpen/section.css +236 -0
- data/app/assets/stylesheets/inkpen/slash_menu.css +252 -0
- data/app/assets/stylesheets/inkpen/sticky_toolbar.css +314 -0
- data/app/assets/stylesheets/inkpen/toc.css +386 -0
- data/app/assets/stylesheets/inkpen/toggle.css +260 -0
- data/app/helpers/inkpen/editor_helper.rb +114 -0
- data/app/views/inkpen/_editor.html.erb +139 -0
- data/config/importmap.rb +170 -0
- data/docs/.DS_Store +0 -0
- data/docs/CHANGELOG.md +571 -0
- data/docs/FEATURES.md +436 -0
- data/docs/ROADMAP.md +3029 -0
- data/docs/VISION.md +235 -0
- data/docs/extensions/INKPEN_TABLE.md +482 -0
- data/docs/thinking/CORRECTED_NO_VUE.md +756 -0
- data/docs/thinking/EXECUTIVE_SUMMARY.md +403 -0
- data/docs/thinking/INKPEN_CODE_SAMPLES.md +1479 -0
- data/docs/thinking/INKPEN_MASTER_GUIDE.md +891 -0
- data/docs/thinking/README_START_HERE.md +341 -0
- data/lib/inkpen/configuration.rb +175 -0
- data/lib/inkpen/editor.rb +204 -0
- data/lib/inkpen/engine.rb +32 -0
- data/lib/inkpen/extensions/base.rb +109 -0
- data/lib/inkpen/extensions/code_block_syntax.rb +177 -0
- data/lib/inkpen/extensions/document_section.rb +111 -0
- data/lib/inkpen/extensions/forced_document.rb +183 -0
- data/lib/inkpen/extensions/mention.rb +155 -0
- data/lib/inkpen/extensions/preformatted.rb +111 -0
- data/lib/inkpen/extensions/section.rb +139 -0
- data/lib/inkpen/extensions/slash_commands.rb +100 -0
- data/lib/inkpen/extensions/table.rb +182 -0
- data/lib/inkpen/extensions/task_list.rb +145 -0
- data/lib/inkpen/sticky_toolbar.rb +157 -0
- data/lib/inkpen/toolbar.rb +145 -0
- data/lib/inkpen/version.rb +5 -0
- data/lib/inkpen.rb +101 -0
- data/sig/inkpen.rbs +4 -0
- metadata +165 -0
|
@@ -0,0 +1,1479 @@
|
|
|
1
|
+
# Inkpen: Code Samples & Implementation Reference
|
|
2
|
+
## Complete Code Examples for INKPEN_MASTER_GUIDE.md
|
|
3
|
+
|
|
4
|
+
This document contains all detailed code samples referenced in the master guide.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## A. Extension Classes
|
|
9
|
+
|
|
10
|
+
### A1: ForcedDocument
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# lib/inkpen/extensions/forced_document.rb
|
|
14
|
+
# frozen_string_literal: true
|
|
15
|
+
|
|
16
|
+
module Inkpen
|
|
17
|
+
module Extensions
|
|
18
|
+
class ForcedDocument < Base
|
|
19
|
+
DEFAULT_TITLE_LEVEL = 1
|
|
20
|
+
DEFAULT_SUBTITLE_LEVEL = 2
|
|
21
|
+
DEFAULT_TITLE_PLACEHOLDER = "Untitled"
|
|
22
|
+
DEFAULT_SUBTITLE_PLACEHOLDER = "Add a subtitle..."
|
|
23
|
+
|
|
24
|
+
def name
|
|
25
|
+
:forced_document
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def title_level
|
|
29
|
+
options.fetch(:title_level, options.fetch(:heading_level, DEFAULT_TITLE_LEVEL))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def subtitle_level
|
|
33
|
+
options.fetch(:subtitle_level, DEFAULT_SUBTITLE_LEVEL)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def subtitle?
|
|
37
|
+
options.fetch(:subtitle, false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def title_placeholder
|
|
41
|
+
options.fetch(:title_placeholder, options.fetch(:placeholder, DEFAULT_TITLE_PLACEHOLDER))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def subtitle_placeholder
|
|
45
|
+
options.fetch(:subtitle_placeholder, DEFAULT_SUBTITLE_PLACEHOLDER)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def allow_title_deletion?
|
|
49
|
+
options.fetch(:allow_deletion, false)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def allow_subtitle_deletion?
|
|
53
|
+
options.fetch(:allow_subtitle_deletion, true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def content_expression
|
|
57
|
+
if subtitle?
|
|
58
|
+
"heading heading? block*"
|
|
59
|
+
else
|
|
60
|
+
"heading block*"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_config
|
|
65
|
+
config = {
|
|
66
|
+
titleLevel: title_level,
|
|
67
|
+
titlePlaceholder: title_placeholder,
|
|
68
|
+
allowDeletion: allow_title_deletion?,
|
|
69
|
+
contentExpression: content_expression,
|
|
70
|
+
subtitle: subtitle?
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if subtitle?
|
|
74
|
+
config.merge!(
|
|
75
|
+
subtitleLevel: subtitle_level,
|
|
76
|
+
subtitlePlaceholder: subtitle_placeholder,
|
|
77
|
+
allowSubtitleDeletion: allow_subtitle_deletion?
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
config[:headingLevel] = title_level
|
|
82
|
+
config
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def default_options
|
|
88
|
+
super.merge(
|
|
89
|
+
title_level: DEFAULT_TITLE_LEVEL,
|
|
90
|
+
subtitle_level: DEFAULT_SUBTITLE_LEVEL,
|
|
91
|
+
title_placeholder: DEFAULT_TITLE_PLACEHOLDER,
|
|
92
|
+
subtitle_placeholder: DEFAULT_SUBTITLE_PLACEHOLDER,
|
|
93
|
+
subtitle: false,
|
|
94
|
+
allow_deletion: false,
|
|
95
|
+
allow_subtitle_deletion: true
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### A2: CodeBlockSyntax
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# lib/inkpen/extensions/code_block_syntax.rb
|
|
107
|
+
# frozen_string_literal: true
|
|
108
|
+
|
|
109
|
+
module Inkpen
|
|
110
|
+
module Extensions
|
|
111
|
+
class CodeBlockSyntax < Base
|
|
112
|
+
AVAILABLE_LANGUAGES = %i[
|
|
113
|
+
javascript typescript ruby python css xml html json
|
|
114
|
+
bash shell sql markdown yaml go rust java kotlin swift
|
|
115
|
+
php c cpp csharp elixir erlang haskell scala r matlab
|
|
116
|
+
dockerfile nginx apache graphql
|
|
117
|
+
].freeze
|
|
118
|
+
|
|
119
|
+
DEFAULT_LANGUAGES = %i[
|
|
120
|
+
javascript typescript ruby python css xml json
|
|
121
|
+
bash sql markdown
|
|
122
|
+
].freeze
|
|
123
|
+
|
|
124
|
+
def name
|
|
125
|
+
:code_block_syntax
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def languages
|
|
129
|
+
options.fetch(:languages, DEFAULT_LANGUAGES)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def default_language
|
|
133
|
+
options[:default_language]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def line_numbers?
|
|
137
|
+
options.fetch(:line_numbers, false)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def show_language_selector?
|
|
141
|
+
options.fetch(:language_selector, true)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def copy_button?
|
|
145
|
+
options.fetch(:copy_button, true)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def theme
|
|
149
|
+
options.fetch(:theme, "github")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def to_config
|
|
153
|
+
{
|
|
154
|
+
languages: languages,
|
|
155
|
+
defaultLanguage: default_language,
|
|
156
|
+
lineNumbers: line_numbers?,
|
|
157
|
+
languageSelector: show_language_selector?,
|
|
158
|
+
copyButton: copy_button?,
|
|
159
|
+
theme: theme,
|
|
160
|
+
lowlight: {
|
|
161
|
+
languages: languages
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def default_options
|
|
169
|
+
super.merge(
|
|
170
|
+
languages: DEFAULT_LANGUAGES,
|
|
171
|
+
line_numbers: false,
|
|
172
|
+
language_selector: true,
|
|
173
|
+
copy_button: true,
|
|
174
|
+
theme: "github"
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### A3: TaskList
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# lib/inkpen/extensions/task_list.rb
|
|
186
|
+
# frozen_string_literal: true
|
|
187
|
+
|
|
188
|
+
module Inkpen
|
|
189
|
+
module Extensions
|
|
190
|
+
class TaskList < Base
|
|
191
|
+
def name
|
|
192
|
+
:task_list
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def nested?
|
|
196
|
+
options.fetch(:nested, true)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def list_class
|
|
200
|
+
options.fetch(:list_class, "inkpen-task-list")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def item_class
|
|
204
|
+
options.fetch(:item_class, "inkpen-task-item")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def checked_class
|
|
208
|
+
options.fetch(:checked_class, "inkpen-task-checked")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def checkbox_class
|
|
212
|
+
options.fetch(:checkbox_class, "inkpen-task-checkbox")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def html_attributes
|
|
216
|
+
options.fetch(:html_attributes, { class: list_class })
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def item_html_attributes
|
|
220
|
+
options.fetch(:item_html_attributes, { class: item_class })
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def text_toggle?
|
|
224
|
+
options.fetch(:text_toggle, false)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def keyboard_shortcut
|
|
228
|
+
options.fetch(:keyboard_shortcut, "Mod-Shift-9")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def to_config
|
|
232
|
+
{
|
|
233
|
+
nested: nested?,
|
|
234
|
+
listClass: list_class,
|
|
235
|
+
itemClass: item_class,
|
|
236
|
+
checkedClass: checked_class,
|
|
237
|
+
checkboxClass: checkbox_class,
|
|
238
|
+
HTMLAttributes: html_attributes,
|
|
239
|
+
itemHTMLAttributes: item_html_attributes,
|
|
240
|
+
textToggle: text_toggle?,
|
|
241
|
+
keyboardShortcut: keyboard_shortcut
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
private
|
|
246
|
+
|
|
247
|
+
def default_options
|
|
248
|
+
super.merge(
|
|
249
|
+
nested: true,
|
|
250
|
+
text_toggle: false,
|
|
251
|
+
keyboard_shortcut: "Mod-Shift-9"
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### A4: Table
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
# lib/inkpen/extensions/table.rb
|
|
263
|
+
# frozen_string_literal: true
|
|
264
|
+
|
|
265
|
+
module Inkpen
|
|
266
|
+
module Extensions
|
|
267
|
+
class Table < Base
|
|
268
|
+
DEFAULT_CELL_MIN_WIDTH = 25
|
|
269
|
+
DEFAULT_CELL_MAX_WIDTH = 500
|
|
270
|
+
|
|
271
|
+
def name
|
|
272
|
+
:table
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def resizable?
|
|
276
|
+
options.fetch(:resizable, true)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def header_row?
|
|
280
|
+
options.fetch(:header_row, true)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def header_column?
|
|
284
|
+
options.fetch(:header_column, false)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def cell_min_width
|
|
288
|
+
options.fetch(:cell_min_width, DEFAULT_CELL_MIN_WIDTH)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def cell_max_width
|
|
292
|
+
options[:cell_max_width]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def toolbar?
|
|
296
|
+
options.fetch(:toolbar, true)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def cell_backgrounds?
|
|
300
|
+
options.fetch(:cell_backgrounds, true)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def allow_merge?
|
|
304
|
+
options.fetch(:allow_merge, true)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def default_rows
|
|
308
|
+
options.fetch(:default_rows, 3)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def default_cols
|
|
312
|
+
options.fetch(:default_cols, 3)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def with_header_row?
|
|
316
|
+
options.fetch(:with_header_row, true)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def to_config
|
|
320
|
+
{
|
|
321
|
+
resizable: resizable?,
|
|
322
|
+
headerRow: header_row?,
|
|
323
|
+
headerColumn: header_column?,
|
|
324
|
+
cellMinWidth: cell_min_width,
|
|
325
|
+
cellMaxWidth: cell_max_width,
|
|
326
|
+
toolbar: toolbar?,
|
|
327
|
+
cellBackgrounds: cell_backgrounds?,
|
|
328
|
+
allowMerge: allow_merge?,
|
|
329
|
+
defaultRows: default_rows,
|
|
330
|
+
defaultCols: default_cols,
|
|
331
|
+
withHeaderRow: with_header_row?
|
|
332
|
+
}.compact
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private
|
|
336
|
+
|
|
337
|
+
def default_options
|
|
338
|
+
super.merge(
|
|
339
|
+
resizable: true,
|
|
340
|
+
header_row: true,
|
|
341
|
+
header_column: false,
|
|
342
|
+
cell_min_width: DEFAULT_CELL_MIN_WIDTH,
|
|
343
|
+
toolbar: true,
|
|
344
|
+
cell_backgrounds: true,
|
|
345
|
+
allow_merge: true,
|
|
346
|
+
default_rows: 3,
|
|
347
|
+
default_cols: 3,
|
|
348
|
+
with_header_row: true
|
|
349
|
+
)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### A5: Mention
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
# lib/inkpen/extensions/mention.rb
|
|
360
|
+
# frozen_string_literal: true
|
|
361
|
+
|
|
362
|
+
module Inkpen
|
|
363
|
+
module Extensions
|
|
364
|
+
class Mention < Base
|
|
365
|
+
DEFAULT_TRIGGER = "@"
|
|
366
|
+
DEFAULT_MIN_CHARS = 1
|
|
367
|
+
|
|
368
|
+
def name
|
|
369
|
+
:mention
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def trigger
|
|
373
|
+
options.fetch(:trigger, DEFAULT_TRIGGER)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def search_url
|
|
377
|
+
options[:search_url]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def items
|
|
381
|
+
options[:items]
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def min_chars
|
|
385
|
+
options.fetch(:min_chars, DEFAULT_MIN_CHARS)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def suggestion_class
|
|
389
|
+
options.fetch(:suggestion_class, "inkpen-mention-suggestions")
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def item_class
|
|
393
|
+
options.fetch(:item_class, "inkpen-mention-item")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def allow_custom?
|
|
397
|
+
options.fetch(:allow_custom, false)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def html_attributes
|
|
401
|
+
options.fetch(:html_attributes, { class: "inkpen-mention" })
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def to_config
|
|
405
|
+
{
|
|
406
|
+
trigger: trigger,
|
|
407
|
+
searchUrl: search_url,
|
|
408
|
+
items: items,
|
|
409
|
+
minChars: min_chars,
|
|
410
|
+
suggestionClass: suggestion_class,
|
|
411
|
+
itemClass: item_class,
|
|
412
|
+
allowCustom: allow_custom?,
|
|
413
|
+
HTMLAttributes: html_attributes
|
|
414
|
+
}.compact
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
private
|
|
418
|
+
|
|
419
|
+
def default_options
|
|
420
|
+
super.merge(
|
|
421
|
+
trigger: DEFAULT_TRIGGER,
|
|
422
|
+
min_chars: DEFAULT_MIN_CHARS,
|
|
423
|
+
allow_custom: false
|
|
424
|
+
)
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### A6: SlashCommands
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
# lib/inkpen/extensions/slash_commands.rb
|
|
435
|
+
# frozen_string_literal: true
|
|
436
|
+
|
|
437
|
+
module Inkpen
|
|
438
|
+
module Extensions
|
|
439
|
+
class SlashCommands < Base
|
|
440
|
+
DEFAULT_COMMANDS = [
|
|
441
|
+
{ name: "paragraph", label: "Text", description: "Plain text block", icon: "text", group: "Basic", shortcut: nil },
|
|
442
|
+
{ name: "heading1", label: "Heading 1", description: "Large heading", icon: "h1", group: "Basic", shortcut: "#" },
|
|
443
|
+
{ name: "heading2", label: "Heading 2", description: "Medium heading", icon: "h2", group: "Basic", shortcut: "##" },
|
|
444
|
+
{ name: "heading3", label: "Heading 3", description: "Small heading", icon: "h3", group: "Basic", shortcut: "###" },
|
|
445
|
+
{ name: "bullet_list", label: "Bullet List", description: "Unordered list", icon: "list", group: "Lists", shortcut: "-" },
|
|
446
|
+
{ name: "ordered_list", label: "Numbered List", description: "Ordered list", icon: "list-ordered", group: "Lists", shortcut: "1." },
|
|
447
|
+
{ name: "task_list", label: "Task List", description: "Checklist with checkboxes", icon: "check-square", group: "Lists", shortcut: "[]" },
|
|
448
|
+
{ name: "blockquote", label: "Quote", description: "Quote block", icon: "quote", group: "Blocks", shortcut: ">" },
|
|
449
|
+
{ name: "code_block", label: "Code Block", description: "Code with syntax highlighting", icon: "code", group: "Blocks", shortcut: "```" },
|
|
450
|
+
{ name: "horizontal_rule", label: "Divider", description: "Horizontal line", icon: "minus", group: "Blocks", shortcut: "---" },
|
|
451
|
+
{ name: "image", label: "Image", description: "Upload or embed an image", icon: "image", group: "Media", shortcut: nil },
|
|
452
|
+
{ name: "youtube", label: "YouTube", description: "Embed a YouTube video", icon: "youtube", group: "Media", shortcut: nil },
|
|
453
|
+
{ name: "table", label: "Table", description: "Insert a table", icon: "table", group: "Advanced", shortcut: nil },
|
|
454
|
+
{ name: "callout", label: "Callout", description: "Highlighted callout box", icon: "alert-circle", group: "Advanced", shortcut: nil }
|
|
455
|
+
].freeze
|
|
456
|
+
|
|
457
|
+
DEFAULT_GROUPS = %w[Basic Lists Blocks Media Advanced].freeze
|
|
458
|
+
|
|
459
|
+
def name
|
|
460
|
+
:slash_commands
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def trigger
|
|
464
|
+
options.fetch(:trigger, "/")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def commands
|
|
468
|
+
options.fetch(:commands, DEFAULT_COMMANDS)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def groups
|
|
472
|
+
options.fetch(:groups, DEFAULT_GROUPS)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def max_suggestions
|
|
476
|
+
options.fetch(:max_suggestions, 10)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def allow_filtering?
|
|
480
|
+
options.fetch(:allow_filtering, true)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def show_icons?
|
|
484
|
+
options.fetch(:show_icons, true)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def show_descriptions?
|
|
488
|
+
options.fetch(:show_descriptions, true)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def show_shortcuts?
|
|
492
|
+
options.fetch(:show_shortcuts, false)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def suggestion_class
|
|
496
|
+
options.fetch(:suggestion_class, "inkpen-slash-menu")
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def item_class
|
|
500
|
+
options.fetch(:item_class, "inkpen-slash-item")
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def active_class
|
|
504
|
+
options.fetch(:active_class, "is-selected")
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def group_class
|
|
508
|
+
options.fetch(:group_class, "inkpen-slash-group")
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def to_config
|
|
512
|
+
{
|
|
513
|
+
trigger: trigger,
|
|
514
|
+
commands: commands,
|
|
515
|
+
groups: groups,
|
|
516
|
+
maxSuggestions: max_suggestions,
|
|
517
|
+
allowFiltering: allow_filtering?,
|
|
518
|
+
showIcons: show_icons?,
|
|
519
|
+
showDescriptions: show_descriptions?,
|
|
520
|
+
showShortcuts: show_shortcuts?,
|
|
521
|
+
suggestionClass: suggestion_class,
|
|
522
|
+
itemClass: item_class,
|
|
523
|
+
activeClass: active_class,
|
|
524
|
+
groupClass: group_class
|
|
525
|
+
}.compact
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
private
|
|
529
|
+
|
|
530
|
+
def default_options
|
|
531
|
+
super.merge(
|
|
532
|
+
trigger: "/",
|
|
533
|
+
commands: DEFAULT_COMMANDS,
|
|
534
|
+
groups: DEFAULT_GROUPS,
|
|
535
|
+
max_suggestions: 10,
|
|
536
|
+
allow_filtering: true,
|
|
537
|
+
show_icons: true,
|
|
538
|
+
show_descriptions: true,
|
|
539
|
+
show_shortcuts: false,
|
|
540
|
+
suggestion_class: "inkpen-slash-menu",
|
|
541
|
+
item_class: "inkpen-slash-item",
|
|
542
|
+
active_class: "is-selected",
|
|
543
|
+
group_class: "inkpen-slash-group"
|
|
544
|
+
)
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## B. Feature Sets
|
|
554
|
+
|
|
555
|
+
### B1: Page Builder Feature Set
|
|
556
|
+
|
|
557
|
+
```ruby
|
|
558
|
+
# lib/inkpen/editor.rb - FEATURE_SETS[:page_builder]
|
|
559
|
+
|
|
560
|
+
module Inkpen
|
|
561
|
+
class TiptapEditor
|
|
562
|
+
FEATURE_SETS = {
|
|
563
|
+
page_builder: {
|
|
564
|
+
text: [:bold, :italic, :underline, :strike],
|
|
565
|
+
headings: [1, 2, 3, 4, 5, 6],
|
|
566
|
+
lists: [:bullet, :ordered, :task],
|
|
567
|
+
media: [:image, :video, :embed],
|
|
568
|
+
layout: [:columns, :hero, :cta, :testimonial, :features_grid],
|
|
569
|
+
advanced: [:table, :code_block, :blockquote, :callout],
|
|
570
|
+
collaboration: [:comments]
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
document: {
|
|
574
|
+
text: [:bold, :italic, :code],
|
|
575
|
+
headings: [1, 2, 3],
|
|
576
|
+
lists: [:bullet, :ordered],
|
|
577
|
+
code: [:code_block, :syntax_highlight],
|
|
578
|
+
collaboration: [:comments, :suggestions, :mentions],
|
|
579
|
+
advanced: [:table, :table_of_contents]
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
standard: {
|
|
583
|
+
text: [:bold, :italic, :underline],
|
|
584
|
+
headings: [1, 2, 3],
|
|
585
|
+
lists: [:bullet, :ordered],
|
|
586
|
+
media: [:image, :link],
|
|
587
|
+
advanced: [:blockquote, :code_block, :callout]
|
|
588
|
+
}
|
|
589
|
+
}.freeze
|
|
590
|
+
|
|
591
|
+
class << self
|
|
592
|
+
def extensions_for(feature_set)
|
|
593
|
+
features = FEATURE_SETS[feature_set.to_sym] || FEATURE_SETS[:standard]
|
|
594
|
+
build_extensions(features)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
private
|
|
598
|
+
|
|
599
|
+
def build_extensions(features)
|
|
600
|
+
extensions = []
|
|
601
|
+
|
|
602
|
+
# Core extensions always included
|
|
603
|
+
extensions << Extensions::ForcedDocument.new(
|
|
604
|
+
subtitle: features[:headings]&.include?(2),
|
|
605
|
+
title_level: 1
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Code block if code features requested
|
|
609
|
+
if features[:code]&.include?(:code_block) || features[:advanced]&.include?(:code_block)
|
|
610
|
+
extensions << Extensions::CodeBlockSyntax.new(
|
|
611
|
+
languages: %i[ruby javascript python sql html css],
|
|
612
|
+
line_numbers: features[:code]&.any?,
|
|
613
|
+
copy_button: true
|
|
614
|
+
)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Task list if lists requested
|
|
618
|
+
if features[:lists]&.include?(:task)
|
|
619
|
+
extensions << Extensions::TaskList.new(nested: true)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Table if advanced table requested
|
|
623
|
+
if features[:advanced]&.include?(:table)
|
|
624
|
+
extensions << Extensions::Table.new(
|
|
625
|
+
resizable: true,
|
|
626
|
+
header_row: true,
|
|
627
|
+
allow_merge: true
|
|
628
|
+
)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Mentions if collaboration features
|
|
632
|
+
if features[:collaboration]&.include?(:mentions)
|
|
633
|
+
extensions << Extensions::Mention.new(
|
|
634
|
+
search_url: "/api/users/search",
|
|
635
|
+
trigger: "@"
|
|
636
|
+
)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Slash commands always included
|
|
640
|
+
extensions << Extensions::SlashCommands.new(
|
|
641
|
+
trigger: "/",
|
|
642
|
+
show_icons: true,
|
|
643
|
+
show_descriptions: true
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
extensions
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
### B2: Document Feature Set
|
|
654
|
+
|
|
655
|
+
(Already included above in B1 as part of FEATURE_SETS)
|
|
656
|
+
|
|
657
|
+
### B3: Standard Feature Set
|
|
658
|
+
|
|
659
|
+
(Already included above in B1 as part of FEATURE_SETS)
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
## C. Integration Pattern
|
|
664
|
+
|
|
665
|
+
### C1: Rails Controller
|
|
666
|
+
|
|
667
|
+
```ruby
|
|
668
|
+
# app/controllers/inkpen/extensions_controller.rb
|
|
669
|
+
|
|
670
|
+
module Inkpen
|
|
671
|
+
class ExtensionsController < ApplicationController
|
|
672
|
+
skip_before_action :verify_authenticity_token, only: [:show]
|
|
673
|
+
|
|
674
|
+
def show
|
|
675
|
+
feature_set = params[:feature_set].to_sym
|
|
676
|
+
extensions = TiptapEditor.extensions_for(feature_set)
|
|
677
|
+
|
|
678
|
+
render json: {
|
|
679
|
+
extensions: extensions.map { |ext| ext.to_config if ext.enabled? }.compact,
|
|
680
|
+
version: Inkpen::VERSION,
|
|
681
|
+
success: true
|
|
682
|
+
}
|
|
683
|
+
rescue => e
|
|
684
|
+
render json: {
|
|
685
|
+
error: e.message,
|
|
686
|
+
success: false
|
|
687
|
+
}, status: :unprocessable_entity
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
### C2: View Helper
|
|
694
|
+
|
|
695
|
+
```ruby
|
|
696
|
+
# app/helpers/inkpen/editor_helper.rb
|
|
697
|
+
|
|
698
|
+
module Inkpen
|
|
699
|
+
module EditorHelper
|
|
700
|
+
def tiptap_editor(model, field, options = {})
|
|
701
|
+
controller_name = options[:controller] || model.class.name.underscore
|
|
702
|
+
features = options[:features] || :standard
|
|
703
|
+
|
|
704
|
+
render "inkpen/editor",
|
|
705
|
+
model: model,
|
|
706
|
+
field: field,
|
|
707
|
+
controller_name: controller_name,
|
|
708
|
+
features: features,
|
|
709
|
+
options: options
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### C3: Stimulus Controller
|
|
716
|
+
|
|
717
|
+
```javascript
|
|
718
|
+
// app/javascript/controllers/inkpen/editor_controller.js
|
|
719
|
+
|
|
720
|
+
import { Controller } from "@hotwired/stimulus"
|
|
721
|
+
import { Editor } from "@tiptap/core"
|
|
722
|
+
import StarterKit from "@tiptap/starter-kit"
|
|
723
|
+
import ExtensionsLoader from "../utils/extensions_loader"
|
|
724
|
+
|
|
725
|
+
export default class extends Controller {
|
|
726
|
+
static targets = ["editor", "content", "title"]
|
|
727
|
+
static values = { features: String, autosave: Boolean }
|
|
728
|
+
|
|
729
|
+
connect() {
|
|
730
|
+
this.initEditor()
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async initEditor() {
|
|
734
|
+
try {
|
|
735
|
+
const config = await this.fetchExtensionsConfig()
|
|
736
|
+
const extensions = await ExtensionsLoader.load(config)
|
|
737
|
+
|
|
738
|
+
this.editor = new Editor({
|
|
739
|
+
element: this.editorTarget,
|
|
740
|
+
extensions: [
|
|
741
|
+
StarterKit.configure({
|
|
742
|
+
heading: { levels: [1, 2, 3, 4, 5, 6] }
|
|
743
|
+
}),
|
|
744
|
+
...extensions
|
|
745
|
+
],
|
|
746
|
+
content: this.contentTarget.value,
|
|
747
|
+
onUpdate: ({ editor }) => {
|
|
748
|
+
this.contentTarget.value = editor.getHTML()
|
|
749
|
+
|
|
750
|
+
if (this.autosaveValue) {
|
|
751
|
+
this.debounce(() => this.autosave(), 1500)
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
this.attachObservers()
|
|
757
|
+
} catch (error) {
|
|
758
|
+
console.error("Failed to initialize editor:", error)
|
|
759
|
+
this.editorTarget.innerHTML = '<p style="color: red;">Failed to load editor</p>'
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async fetchExtensionsConfig() {
|
|
764
|
+
const response = await fetch(`/inkpen/extensions/${this.featuresValue}.json`)
|
|
765
|
+
if (!response.ok) throw new Error(`Failed to fetch extensions: ${response.status}`)
|
|
766
|
+
return await response.json()
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
autosave() {
|
|
770
|
+
if (!this.form) return
|
|
771
|
+
|
|
772
|
+
const formData = new FormData(this.form)
|
|
773
|
+
fetch(this.form.action, {
|
|
774
|
+
method: "PATCH",
|
|
775
|
+
body: formData,
|
|
776
|
+
headers: { "X-CSRF-Token": this.csrfToken }
|
|
777
|
+
})
|
|
778
|
+
.then(r => {
|
|
779
|
+
if (r.ok) this.showAutosaveIndicator()
|
|
780
|
+
})
|
|
781
|
+
.catch(e => console.error("Autosave failed:", e))
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
showAutosaveIndicator() {
|
|
785
|
+
// Show "Saved" indicator briefly
|
|
786
|
+
const indicator = document.createElement("div")
|
|
787
|
+
indicator.className = "autosave-indicator"
|
|
788
|
+
indicator.textContent = "✓ Saved"
|
|
789
|
+
document.body.appendChild(indicator)
|
|
790
|
+
|
|
791
|
+
setTimeout(() => indicator.remove(), 2000)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
attachObservers() {
|
|
795
|
+
// Additional event handlers
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
disconnect() {
|
|
799
|
+
if (this.editor) this.editor.destroy()
|
|
800
|
+
clearTimeout(this.debounceTimer)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
debounce(fn, delay) {
|
|
804
|
+
clearTimeout(this.debounceTimer)
|
|
805
|
+
this.debounceTimer = setTimeout(fn, delay)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
get form() {
|
|
809
|
+
return this.element.closest("form")
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
get csrfToken() {
|
|
813
|
+
return document.querySelector('meta[name="csrf-token"]')?.content || ""
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
### C4: Extensions Loader
|
|
819
|
+
|
|
820
|
+
```javascript
|
|
821
|
+
// app/javascript/utils/extensions_loader.js
|
|
822
|
+
|
|
823
|
+
import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"
|
|
824
|
+
import { lowlight } from "lowlight"
|
|
825
|
+
import { TaskList } from "@tiptap/extension-task-list"
|
|
826
|
+
import { TaskItem } from "@tiptap/extension-task-item"
|
|
827
|
+
import { Table } from "@tiptap/extension-table"
|
|
828
|
+
import { TableRow } from "@tiptap/extension-table-row"
|
|
829
|
+
import { TableHeader } from "@tiptap/extension-table-header"
|
|
830
|
+
import { TableCell } from "@tiptap/extension-table-cell"
|
|
831
|
+
import { Mention } from "@tiptap/extension-mention"
|
|
832
|
+
import MentionList from "./components/mention_list"
|
|
833
|
+
import CommandPalette from "./components/command_palette"
|
|
834
|
+
import tippy from "tippy.js"
|
|
835
|
+
|
|
836
|
+
export default class ExtensionsLoader {
|
|
837
|
+
static async load(config) {
|
|
838
|
+
const extensions = []
|
|
839
|
+
|
|
840
|
+
for (const extConfig of config.extensions) {
|
|
841
|
+
const ext = await this.loadExtension(extConfig)
|
|
842
|
+
if (ext) extensions.push(ext)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return extensions
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
static async loadExtension(config) {
|
|
849
|
+
switch (config.name) {
|
|
850
|
+
case "forced_document":
|
|
851
|
+
return this.configureForcedDocument(config)
|
|
852
|
+
|
|
853
|
+
case "code_block_syntax":
|
|
854
|
+
return this.configureCodeBlock(config)
|
|
855
|
+
|
|
856
|
+
case "task_list":
|
|
857
|
+
return this.configureTaskList(config)
|
|
858
|
+
|
|
859
|
+
case "table":
|
|
860
|
+
return this.configureTable(config)
|
|
861
|
+
|
|
862
|
+
case "mention":
|
|
863
|
+
return this.configureMention(config)
|
|
864
|
+
|
|
865
|
+
case "slash_commands":
|
|
866
|
+
return this.configureSlashCommands(config)
|
|
867
|
+
|
|
868
|
+
default:
|
|
869
|
+
return null
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
static configureForcedDocument(config) {
|
|
874
|
+
// Load ForcedDocument extension from TipTap
|
|
875
|
+
const ForcedDocument = require("@tiptap/extension-forced-document").default
|
|
876
|
+
|
|
877
|
+
return ForcedDocument.configure({
|
|
878
|
+
titleLevel: config.config.titleLevel,
|
|
879
|
+
titlePlaceholder: config.config.titlePlaceholder,
|
|
880
|
+
subtitle: config.config.subtitle,
|
|
881
|
+
subtitleLevel: config.config.subtitleLevel
|
|
882
|
+
})
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
static configureCodeBlock(config) {
|
|
886
|
+
const languages = {}
|
|
887
|
+
for (const lang of config.config.languages) {
|
|
888
|
+
languages[lang] = require(`highlight.js/lib/languages/${lang}`).default
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return CodeBlockLowlight.configure({
|
|
892
|
+
lowlight,
|
|
893
|
+
languages,
|
|
894
|
+
defaultLanguage: config.config.defaultLanguage
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
static configureTaskList(config) {
|
|
899
|
+
return [
|
|
900
|
+
TaskList.configure(config.config),
|
|
901
|
+
TaskItem.configure({ nested: config.config.nested })
|
|
902
|
+
]
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
static configureTable(config) {
|
|
906
|
+
return [
|
|
907
|
+
Table.configure({
|
|
908
|
+
resizable: config.config.resizable,
|
|
909
|
+
handleWidth: 4,
|
|
910
|
+
cellMinWidth: config.config.cellMinWidth,
|
|
911
|
+
lastColumnResizable: true
|
|
912
|
+
}),
|
|
913
|
+
TableRow,
|
|
914
|
+
TableHeader,
|
|
915
|
+
TableCell
|
|
916
|
+
]
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
static configureMention(config) {
|
|
920
|
+
return Mention.configure({
|
|
921
|
+
HTMLAttributes: { class: "mention" },
|
|
922
|
+
|
|
923
|
+
suggestion: {
|
|
924
|
+
items: async ({ query }) => {
|
|
925
|
+
if (!query) return []
|
|
926
|
+
|
|
927
|
+
try {
|
|
928
|
+
const response = await fetch(
|
|
929
|
+
`${config.config.searchUrl}?query=${encodeURIComponent(query)}`
|
|
930
|
+
)
|
|
931
|
+
return await response.json()
|
|
932
|
+
} catch (e) {
|
|
933
|
+
console.error("Mention search failed:", e)
|
|
934
|
+
return []
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
|
|
938
|
+
render: () => {
|
|
939
|
+
let component
|
|
940
|
+
let popup
|
|
941
|
+
|
|
942
|
+
return {
|
|
943
|
+
onStart: props => {
|
|
944
|
+
component = new MentionList(props)
|
|
945
|
+
popup = tippy("body", {
|
|
946
|
+
getReferenceClientRect: props.clientRect,
|
|
947
|
+
appendTo: () => document.body,
|
|
948
|
+
content: component.element,
|
|
949
|
+
showOnCreate: true,
|
|
950
|
+
interactive: true,
|
|
951
|
+
trigger: "manual",
|
|
952
|
+
placement: "bottom-start"
|
|
953
|
+
})[0]
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
onUpdate(props) {
|
|
957
|
+
component.update(props)
|
|
958
|
+
},
|
|
959
|
+
|
|
960
|
+
onKeyDown(props) {
|
|
961
|
+
return component.onKeyDown(props)
|
|
962
|
+
},
|
|
963
|
+
|
|
964
|
+
onExit() {
|
|
965
|
+
popup?.destroy()
|
|
966
|
+
component?.destroy()
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
static configureSlashCommands(config) {
|
|
975
|
+
const SlashCommand = require("@tiptap/extension-slash-command").default
|
|
976
|
+
|
|
977
|
+
return SlashCommand.configure({
|
|
978
|
+
suggestion: {
|
|
979
|
+
items: ({ query }) => {
|
|
980
|
+
return config.config.commands
|
|
981
|
+
.filter(cmd =>
|
|
982
|
+
cmd.label.toLowerCase().startsWith(query.toLowerCase())
|
|
983
|
+
)
|
|
984
|
+
.slice(0, config.config.maxSuggestions)
|
|
985
|
+
},
|
|
986
|
+
|
|
987
|
+
render: () => {
|
|
988
|
+
let component
|
|
989
|
+
let popup
|
|
990
|
+
|
|
991
|
+
return {
|
|
992
|
+
onStart: props => {
|
|
993
|
+
component = new CommandPalette(props)
|
|
994
|
+
popup = tippy("body", {
|
|
995
|
+
getReferenceClientRect: props.clientRect,
|
|
996
|
+
appendTo: () => document.body,
|
|
997
|
+
content: component.element,
|
|
998
|
+
showOnCreate: true,
|
|
999
|
+
interactive: true,
|
|
1000
|
+
trigger: "manual",
|
|
1001
|
+
placement: "bottom-start"
|
|
1002
|
+
})[0]
|
|
1003
|
+
},
|
|
1004
|
+
|
|
1005
|
+
onUpdate(props) {
|
|
1006
|
+
component.update(props)
|
|
1007
|
+
},
|
|
1008
|
+
|
|
1009
|
+
onKeyDown(props) {
|
|
1010
|
+
return component.onKeyDown(props)
|
|
1011
|
+
},
|
|
1012
|
+
|
|
1013
|
+
onExit() {
|
|
1014
|
+
popup?.destroy()
|
|
1015
|
+
component?.destroy()
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
})
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
### C5: TipTap Initialization
|
|
1026
|
+
|
|
1027
|
+
(Covered in C3 Stimulus Controller above)
|
|
1028
|
+
|
|
1029
|
+
---
|
|
1030
|
+
|
|
1031
|
+
## D. Custom Blocks
|
|
1032
|
+
|
|
1033
|
+
### D1: Custom Block - Hero
|
|
1034
|
+
|
|
1035
|
+
```ruby
|
|
1036
|
+
# lib/inkpen/extensions/hero.rb
|
|
1037
|
+
# frozen_string_literal: true
|
|
1038
|
+
|
|
1039
|
+
module Inkpen
|
|
1040
|
+
module Extensions
|
|
1041
|
+
class Hero < Base
|
|
1042
|
+
def name
|
|
1043
|
+
:hero
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
def background_image_url
|
|
1047
|
+
options[:background_image_url]
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
def headline
|
|
1051
|
+
options[:headline]
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def subheadline
|
|
1055
|
+
options[:subheadline]
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
def cta_text
|
|
1059
|
+
options[:cta_text]
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def cta_url
|
|
1063
|
+
options[:cta_url]
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
def text_align
|
|
1067
|
+
options.fetch(:text_align, "center") # left, center, right
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
def dark_overlay?
|
|
1071
|
+
options.fetch(:dark_overlay, false)
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
def to_config
|
|
1075
|
+
{
|
|
1076
|
+
backgroundImageUrl: background_image_url,
|
|
1077
|
+
headline: headline,
|
|
1078
|
+
subheadline: subheadline,
|
|
1079
|
+
ctaText: cta_text,
|
|
1080
|
+
ctaUrl: cta_url,
|
|
1081
|
+
textAlign: text_align,
|
|
1082
|
+
darkOverlay: dark_overlay?
|
|
1083
|
+
}.compact
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
private
|
|
1087
|
+
|
|
1088
|
+
def default_options
|
|
1089
|
+
super.merge(
|
|
1090
|
+
text_align: "center",
|
|
1091
|
+
dark_overlay: false
|
|
1092
|
+
)
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
JavaScript implementation:
|
|
1100
|
+
|
|
1101
|
+
```javascript
|
|
1102
|
+
// app/javascript/extensions/hero.js
|
|
1103
|
+
|
|
1104
|
+
import { Node } from "@tiptap/core"
|
|
1105
|
+
import { VueNodeViewRenderer } from "@tiptap/vue-3"
|
|
1106
|
+
import HeroComponent from "../components/hero_block.vue"
|
|
1107
|
+
|
|
1108
|
+
export const HeroBlock = Node.create({
|
|
1109
|
+
name: "hero",
|
|
1110
|
+
group: "block",
|
|
1111
|
+
atom: true,
|
|
1112
|
+
draggable: true,
|
|
1113
|
+
selectable: true,
|
|
1114
|
+
|
|
1115
|
+
addAttributes() {
|
|
1116
|
+
return {
|
|
1117
|
+
backgroundImageUrl: { default: null },
|
|
1118
|
+
headline: { default: "Your Headline Here" },
|
|
1119
|
+
subheadline: { default: "Add a subheadline" },
|
|
1120
|
+
ctaText: { default: "Learn More" },
|
|
1121
|
+
ctaUrl: { default: "#" },
|
|
1122
|
+
textAlign: { default: "center" },
|
|
1123
|
+
darkOverlay: { default: false }
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
parseHTML() {
|
|
1128
|
+
return [{ tag: "hero-block" }]
|
|
1129
|
+
},
|
|
1130
|
+
|
|
1131
|
+
renderHTML({ HTMLAttributes }) {
|
|
1132
|
+
return ["hero-block", HTMLAttributes]
|
|
1133
|
+
},
|
|
1134
|
+
|
|
1135
|
+
addNodeView() {
|
|
1136
|
+
return VueNodeViewRenderer(HeroComponent)
|
|
1137
|
+
}
|
|
1138
|
+
})
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
---
|
|
1142
|
+
|
|
1143
|
+
## E. Implementation Steps
|
|
1144
|
+
|
|
1145
|
+
### E1: Gem Setup
|
|
1146
|
+
|
|
1147
|
+
```ruby
|
|
1148
|
+
# lib/inkpen.rb
|
|
1149
|
+
require "inkpen/version"
|
|
1150
|
+
require "inkpen/engine"
|
|
1151
|
+
require "inkpen/extensions"
|
|
1152
|
+
|
|
1153
|
+
module Inkpen
|
|
1154
|
+
class << self
|
|
1155
|
+
attr_accessor :configuration
|
|
1156
|
+
|
|
1157
|
+
def configure
|
|
1158
|
+
self.configuration ||= Configuration.new
|
|
1159
|
+
yield(configuration)
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
def config
|
|
1163
|
+
configuration ||= Configuration.new
|
|
1164
|
+
end
|
|
1165
|
+
end
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# lib/inkpen/engine.rb
|
|
1169
|
+
module Inkpen
|
|
1170
|
+
class Engine < ::Rails::Engine
|
|
1171
|
+
isolate_namespace Inkpen
|
|
1172
|
+
|
|
1173
|
+
initializer "inkpen.assets" do |app|
|
|
1174
|
+
app.config.assets.precompile += %w[inkpen_manifest.js]
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
initializer "inkpen.helpers" do
|
|
1178
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
1179
|
+
helper Inkpen::EditorHelper
|
|
1180
|
+
end
|
|
1181
|
+
end
|
|
1182
|
+
end
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
# lib/inkpen/configuration.rb
|
|
1186
|
+
module Inkpen
|
|
1187
|
+
class Configuration
|
|
1188
|
+
attr_accessor :app_name, :custom_blocks, :feature_sets
|
|
1189
|
+
|
|
1190
|
+
def initialize
|
|
1191
|
+
@app_name = "inkpen"
|
|
1192
|
+
@custom_blocks = []
|
|
1193
|
+
@feature_sets = {}
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
def register_block(name, block_class)
|
|
1197
|
+
@custom_blocks << { name: name, class: block_class }
|
|
1198
|
+
end
|
|
1199
|
+
end
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
# inkpen.gemspec
|
|
1203
|
+
Gem::Specification.new do |spec|
|
|
1204
|
+
spec.name = "inkpen"
|
|
1205
|
+
spec.version = Inkpen::VERSION
|
|
1206
|
+
spec.authors = ["Your Name"]
|
|
1207
|
+
spec.email = ["your.email@example.com"]
|
|
1208
|
+
spec.summary = "Rich text editor for Rails with TipTap"
|
|
1209
|
+
spec.homepage = "https://github.com/yourname/inkpen"
|
|
1210
|
+
spec.license = "MIT"
|
|
1211
|
+
|
|
1212
|
+
spec.files = Dir.glob("lib/**/*") + Dir.glob("app/**/*") + %w[README.md]
|
|
1213
|
+
|
|
1214
|
+
spec.add_dependency "rails", ">= 7.0"
|
|
1215
|
+
spec.add_dependency "stimulus-rails", ">= 1.0"
|
|
1216
|
+
spec.add_dependency "jsbundling-rails", ">= 1.0"
|
|
1217
|
+
|
|
1218
|
+
spec.add_development_dependency "rspec-rails"
|
|
1219
|
+
spec.add_development_dependency "bundler"
|
|
1220
|
+
spec.add_development_dependency "rake"
|
|
1221
|
+
end
|
|
1222
|
+
```
|
|
1223
|
+
|
|
1224
|
+
### E2: Backend Setup
|
|
1225
|
+
|
|
1226
|
+
```ruby
|
|
1227
|
+
# lib/inkpen/editor.rb
|
|
1228
|
+
module Inkpen
|
|
1229
|
+
class TiptapEditor
|
|
1230
|
+
FEATURE_SETS = {
|
|
1231
|
+
page_builder: { ... },
|
|
1232
|
+
document: { ... },
|
|
1233
|
+
standard: { ... }
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
class << self
|
|
1237
|
+
def extensions_for(feature_set)
|
|
1238
|
+
features = FEATURE_SETS[feature_set.to_sym] || FEATURE_SETS[:standard]
|
|
1239
|
+
build_extensions(features)
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
private
|
|
1243
|
+
|
|
1244
|
+
def build_extensions(features)
|
|
1245
|
+
# Implementation (see section B above)
|
|
1246
|
+
end
|
|
1247
|
+
end
|
|
1248
|
+
end
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
# config/routes.rb (in gem)
|
|
1252
|
+
Inkpen::Engine.routes.draw do
|
|
1253
|
+
namespace :inkpen do
|
|
1254
|
+
resources :extensions, only: [:show]
|
|
1255
|
+
end
|
|
1256
|
+
end
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
### E3: Frontend Setup
|
|
1260
|
+
|
|
1261
|
+
(See C3 and C4 above for Stimulus and ExtensionsLoader)
|
|
1262
|
+
|
|
1263
|
+
### E4: Styling Setup
|
|
1264
|
+
|
|
1265
|
+
```css
|
|
1266
|
+
/* app/assets/stylesheets/inkpen/editor.css */
|
|
1267
|
+
|
|
1268
|
+
:root {
|
|
1269
|
+
--inkpen-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1270
|
+
--inkpen-font-size: 1rem;
|
|
1271
|
+
--inkpen-line-height: 1.6;
|
|
1272
|
+
--inkpen-color-text: #1a1a1a;
|
|
1273
|
+
--inkpen-color-text-muted: #666666;
|
|
1274
|
+
--inkpen-color-background: #fafafa;
|
|
1275
|
+
--inkpen-color-border: #e5e5e5;
|
|
1276
|
+
--inkpen-color-primary: #3b82f6;
|
|
1277
|
+
--inkpen-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
1278
|
+
--inkpen-radius: 6px;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
.inkpen-editor {
|
|
1282
|
+
font-family: var(--inkpen-font-family);
|
|
1283
|
+
font-size: var(--inkpen-font-size);
|
|
1284
|
+
line-height: var(--inkpen-line-height);
|
|
1285
|
+
color: var(--inkpen-color-text);
|
|
1286
|
+
background: var(--inkpen-color-background);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
.inkpen-editor__content {
|
|
1290
|
+
outline: none;
|
|
1291
|
+
min-height: 200px;
|
|
1292
|
+
padding: 1rem;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/* Dark mode */
|
|
1296
|
+
@media (prefers-color-scheme: dark) {
|
|
1297
|
+
:root {
|
|
1298
|
+
--inkpen-color-text: #ffffff;
|
|
1299
|
+
--inkpen-color-background: #111111;
|
|
1300
|
+
--inkpen-color-primary: #60a5fa;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
### E5: Testing Setup
|
|
1306
|
+
|
|
1307
|
+
```ruby
|
|
1308
|
+
# spec/lib/inkpen/extensions/code_block_syntax_spec.rb
|
|
1309
|
+
|
|
1310
|
+
RSpec.describe Inkpen::Extensions::CodeBlockSyntax do
|
|
1311
|
+
describe "#to_config" do
|
|
1312
|
+
it "converts to JSON-safe hash" do
|
|
1313
|
+
ext = described_class.new(
|
|
1314
|
+
languages: %i[ruby javascript],
|
|
1315
|
+
copy_button: true
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
config = ext.to_config
|
|
1319
|
+
expect(config).to be_a(Hash)
|
|
1320
|
+
expect(config[:languages]).to eq(%i[ruby javascript])
|
|
1321
|
+
expect(config[:copyButton]).to eq(true)
|
|
1322
|
+
end
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
describe "#name" do
|
|
1326
|
+
it "returns :code_block_syntax" do
|
|
1327
|
+
ext = described_class.new
|
|
1328
|
+
expect(ext.name).to eq(:code_block_syntax)
|
|
1329
|
+
end
|
|
1330
|
+
end
|
|
1331
|
+
end
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
### E6: Deployment
|
|
1335
|
+
|
|
1336
|
+
```bash
|
|
1337
|
+
# Build and publish gem
|
|
1338
|
+
gem build inkpen.gemspec
|
|
1339
|
+
# gem push inkpen-1.0.0.gem (to rubygems.org)
|
|
1340
|
+
# or push to private gem server
|
|
1341
|
+
|
|
1342
|
+
# In app Gemfile
|
|
1343
|
+
gem 'inkpen', '~> 1.0.0', git: 'https://github.com/yourname/inkpen.git'
|
|
1344
|
+
|
|
1345
|
+
# Run installer
|
|
1346
|
+
rails generate inkpen:install
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
---
|
|
1350
|
+
|
|
1351
|
+
## F. Testing
|
|
1352
|
+
|
|
1353
|
+
### F1: Unit Tests
|
|
1354
|
+
|
|
1355
|
+
```ruby
|
|
1356
|
+
# spec/lib/inkpen/extensions/base_spec.rb
|
|
1357
|
+
|
|
1358
|
+
RSpec.describe Inkpen::Extensions::Base do
|
|
1359
|
+
subject { described_class.new(enabled: true, custom: "value") }
|
|
1360
|
+
|
|
1361
|
+
describe "#initialize" do
|
|
1362
|
+
it "merges options with defaults" do
|
|
1363
|
+
expect(subject.options[:enabled]).to eq(true)
|
|
1364
|
+
expect(subject.options[:custom]).to eq("value")
|
|
1365
|
+
end
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
describe "#enabled?" do
|
|
1369
|
+
it "returns true by default" do
|
|
1370
|
+
ext = described_class.new
|
|
1371
|
+
expect(ext.enabled?).to eq(true)
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1374
|
+
it "respects enabled option" do
|
|
1375
|
+
ext = described_class.new(enabled: false)
|
|
1376
|
+
expect(ext.enabled?).to eq(false)
|
|
1377
|
+
end
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
describe "#to_json" do
|
|
1381
|
+
it "serializes to valid JSON" do
|
|
1382
|
+
ext = described_class.new
|
|
1383
|
+
json = ext.to_json
|
|
1384
|
+
expect { JSON.parse(json) }.not_to raise_error
|
|
1385
|
+
end
|
|
1386
|
+
end
|
|
1387
|
+
end
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
### F2: Integration Tests
|
|
1391
|
+
|
|
1392
|
+
```ruby
|
|
1393
|
+
# spec/requests/inkpen/extensions_spec.rb
|
|
1394
|
+
|
|
1395
|
+
RSpec.describe "Inkpen Extensions" do
|
|
1396
|
+
describe "GET /inkpen/extensions/:feature_set.json" do
|
|
1397
|
+
it "returns page_builder extensions" do
|
|
1398
|
+
get "/inkpen/extensions/page_builder.json"
|
|
1399
|
+
expect(response).to be_successful
|
|
1400
|
+
|
|
1401
|
+
json = JSON.parse(response.body)
|
|
1402
|
+
expect(json["extensions"]).to be_an(Array)
|
|
1403
|
+
expect(json["extensions"].length).to be > 0
|
|
1404
|
+
end
|
|
1405
|
+
end
|
|
1406
|
+
end
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
### F3: Browser Tests
|
|
1410
|
+
|
|
1411
|
+
```javascript
|
|
1412
|
+
// spec/browser/editor_spec.js (Cypress/Playwright)
|
|
1413
|
+
|
|
1414
|
+
describe("Inkpen Editor", () => {
|
|
1415
|
+
before(() => {
|
|
1416
|
+
cy.visit("/pages/new")
|
|
1417
|
+
})
|
|
1418
|
+
|
|
1419
|
+
it("loads editor without errors", () => {
|
|
1420
|
+
cy.get('[data-controller="inkpen--editor"]').should("exist")
|
|
1421
|
+
cy.get('.ProseMirror').should("be.visible")
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
it("allows typing in editor", () => {
|
|
1425
|
+
cy.get('.ProseMirror').type("Hello, world!")
|
|
1426
|
+
cy.get('.ProseMirror').should("contain", "Hello, world!")
|
|
1427
|
+
})
|
|
1428
|
+
|
|
1429
|
+
it("shows slash command palette", () => {
|
|
1430
|
+
cy.get('.ProseMirror').type("/")
|
|
1431
|
+
cy.get('[data-inkpen-target="slash-menu"]').should("be.visible")
|
|
1432
|
+
})
|
|
1433
|
+
})
|
|
1434
|
+
```
|
|
1435
|
+
|
|
1436
|
+
---
|
|
1437
|
+
|
|
1438
|
+
## G. Changelog Template
|
|
1439
|
+
|
|
1440
|
+
```markdown
|
|
1441
|
+
# CHANGELOG.md
|
|
1442
|
+
|
|
1443
|
+
## [1.2.0] - 2024-01-15
|
|
1444
|
+
|
|
1445
|
+
### Added
|
|
1446
|
+
- ForcedDocument extension for document structure enforcement
|
|
1447
|
+
- CodeBlockSyntax extension with 30+ language support
|
|
1448
|
+
- TaskList extension with nesting support
|
|
1449
|
+
- Table extension with cell merging and resizing
|
|
1450
|
+
- Mention extension for @mentions
|
|
1451
|
+
- SlashCommands extension with 13 built-in commands
|
|
1452
|
+
- Page Builder feature set for mademysite.com
|
|
1453
|
+
- Document feature set for kuickr.co
|
|
1454
|
+
- Standard feature set for blog posts
|
|
1455
|
+
|
|
1456
|
+
### Changed
|
|
1457
|
+
- Refactored extension loading system for better modularity
|
|
1458
|
+
- Simplified Stimulus controller API
|
|
1459
|
+
|
|
1460
|
+
### Fixed
|
|
1461
|
+
- Fixed dark mode CSS variable scoping
|
|
1462
|
+
- Fixed autosave timing issues
|
|
1463
|
+
|
|
1464
|
+
## [1.1.0] - 2024-01-01
|
|
1465
|
+
|
|
1466
|
+
### Added
|
|
1467
|
+
- Initial gem release
|
|
1468
|
+
- Basic Rails::Engine setup
|
|
1469
|
+
- Stimulus controller integration
|
|
1470
|
+
|
|
1471
|
+
### Fixed
|
|
1472
|
+
- Initial bug fixes from alpha testing
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
---
|
|
1476
|
+
|
|
1477
|
+
**End of Code Samples Document**
|
|
1478
|
+
|
|
1479
|
+
All code samples reference either existing files you provided or implementation patterns based on the Inkpen architecture.
|