milkdown_engine 0.1.1 → 0.1.2

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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/milkdown_engine/application.css +390 -0
  3. data/app/controllers/milkdown_engine/application_controller.rb +1 -1
  4. data/app/controllers/milkdown_engine/notes_controller.rb +62 -0
  5. data/app/helpers/milkdown_engine/application_helper.rb +19 -8
  6. data/app/javascript/milkdown_engine/controllers/auto_submit_controller.js +10 -0
  7. data/app/javascript/milkdown_engine/controllers/editor_controller.js +1 -1
  8. data/app/javascript/milkdown_engine/controllers/item_list_controller.js +29 -0
  9. data/app/models/milkdown_engine/{md_document.rb → document.rb} +17 -5
  10. data/app/views/milkdown_engine/notes/_content_editor.html.ruby +3 -0
  11. data/app/views/milkdown_engine/notes/_content_form.html.ruby +3 -0
  12. data/app/views/milkdown_engine/notes/_form.html.ruby +27 -0
  13. data/app/views/milkdown_engine/notes/_item_list_panel.html.ruby +22 -0
  14. data/app/views/milkdown_engine/notes/_navigation_rail.html.ruby +30 -0
  15. data/app/views/milkdown_engine/notes/_note_card.html.ruby +9 -0
  16. data/app/views/milkdown_engine/notes/_title_form.html.ruby +3 -0
  17. data/app/views/milkdown_engine/notes/edit.html.ruby +11 -0
  18. data/app/views/milkdown_engine/notes/index.html.ruby +11 -0
  19. data/app/views/milkdown_engine/notes/new.html.ruby +11 -0
  20. data/app/views/milkdown_engine/notes/show.html.ruby +11 -0
  21. data/config/importmap.rb +3 -0
  22. data/config/routes.rb +1 -0
  23. data/db/migrate/20260327000000_create_acts_as_taggable_on_tables.rb +40 -0
  24. data/db/migrate/20260327100000_rename_md_documents_to_documents.rb +7 -0
  25. data/lib/milkdown_engine/engine.rb +8 -0
  26. data/lib/milkdown_engine/version.rb +1 -1
  27. metadata +46 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc838ab80db68a94955f43200672ffe5cb687b3741cb565ac036bb99b8f17bb7
4
- data.tar.gz: 2052f7718e5baf506706854b6e6f25d6b68406aad01283bd8eb9b3d2832313fa
3
+ metadata.gz: cae76a4c5e5b3f031abb244592b8409c33d85e3cd590536ce62017d707d38314
4
+ data.tar.gz: f50de7f3bb331329dcbb8642d51e7464d97f6379416cd9db1b0b7dd9d15bfd1c
5
5
  SHA512:
6
- metadata.gz: b68068e9a1fd786427f6b0d08bfef4e7ecc2fd39b436d7363c1728216e1a2debb7fdf02784a4d4da3e04905610c8bcb8a007f343c1334facb14ef329d8fadf5e
7
- data.tar.gz: 35d58acd3ba8b3a8c186b2d8f8719a25a14244d8ac252cc35261f7e0716cc3d07144f7f878e53661c2dcd358ee136fbc7396f0315fc39fca783bf3aca4e0a9b4
6
+ metadata.gz: f1070fe2f7f90718c6bdda8362f940d6a1e7ee9efcdf9b444172a06b044970cc0da7bbfa2040b4a4ec9628d43a51f7e94b81d8398d69c2239a56291993f30b42
7
+ data.tar.gz: 982f0725cdddc3bacd8cfeecdb202f666f8661dc23590c0231bacda39d30a780100ddb0f731f2c912e1db22b024cf43984ea46579a9f352522a98e0b328900fe
@@ -10,6 +10,14 @@
10
10
  .milkdown-editor.ui.segment {
11
11
  padding: 0;
12
12
  position: relative;
13
+ display: flex;
14
+ flex-direction: column;
15
+ flex: 1;
16
+ overflow: hidden;
17
+ margin: 0;
18
+ border: none;
19
+ box-shadow: none;
20
+ border-radius: 0;
13
21
  }
14
22
 
15
23
  .milkdown-editor-label {
@@ -18,11 +26,52 @@
18
26
  font-weight: 700;
19
27
  font-size: 0.92857143em;
20
28
  color: rgba(0, 0, 0, 0.87);
29
+ flex-shrink: 0;
21
30
  }
22
31
 
23
32
  /* The inner surface where Crepe mounts the ProseMirror editor. */
24
33
  .milkdown-editor-surface {
25
34
  min-height: 12rem;
35
+ flex: 1;
36
+ overflow-y: auto;
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+
41
+ .milkdown-editor-surface .milkdown {
42
+ display: flex;
43
+ flex-direction: column;
44
+ flex: 1;
45
+ min-height: 100%;
46
+ }
47
+
48
+ .milkdown-editor-surface .milkdown .ProseMirror {
49
+ padding: 15px 15px 15px 40px;
50
+ flex: 1;
51
+ min-height: 100%;
52
+ cursor: text;
53
+ }
54
+
55
+ /* Title bar: fixed at top, no grow */
56
+ #content-editor .editor-title-bar {
57
+ flex-shrink: 0;
58
+ border-bottom: 1px solid #dfe1e4;
59
+ }
60
+
61
+ /* Content form: fills remaining space, scrolls via milkdown surface */
62
+ #content-editor .editor-content {
63
+ display: flex;
64
+ flex-direction: column;
65
+ flex: 1;
66
+ overflow: hidden;
67
+ }
68
+
69
+ #content-editor .editor-content .ui.form {
70
+ display: flex;
71
+ flex-direction: column;
72
+ flex: 1;
73
+ overflow: hidden;
74
+ margin: 0;
26
75
  }
27
76
 
28
77
  /* When used inside a Fomantic form field, drop the segment border so it
@@ -38,3 +87,344 @@
38
87
  opacity: 0.75;
39
88
  pointer-events: none;
40
89
  }
90
+
91
+ /* ═══════════════════════════════════════════════════════════════════════
92
+ Three-panel editor layout
93
+ ═══════════════════════════════════════════════════════════════════════ */
94
+
95
+ /* Prevent parent from scrolling when the editor layout is active */
96
+ #site-content-scroll:has(#md-app) {
97
+ overflow-y: hidden;
98
+ }
99
+
100
+ /* ── App shell grid ──────────────────────────────────── */
101
+ #md-app {
102
+ display: grid;
103
+ grid-template-columns: 360px 1fr;
104
+ grid-template-rows: 1fr;
105
+ grid-template-areas:
106
+ "item-list-panel content-editor";
107
+ height: 100%;
108
+ }
109
+
110
+ /* ── Zone borders / backgrounds ──────────────────────── */
111
+ #navigation-rail { grid-area: navigation-rail; background: #f0ede8; border-right: 1px solid #dddbd7; display: none; }
112
+ #item-list-panel { grid-area: item-list-panel; background: #faf9f7; border-right: 1px solid #dddbd7; }
113
+ #content-editor { grid-area: content-editor; background: #ffffff; }
114
+ #system-bar { grid-area: system-bar; background: #f0ede8; border-top: 1px solid #dddbd7; }
115
+
116
+ /* ── Navigation rail ────────────────────────────────── */
117
+ #navigation-rail {
118
+ display: flex;
119
+ flex-direction: column;
120
+ overflow: hidden;
121
+ }
122
+
123
+ #navigation-rail-search {
124
+ padding: 10px 10px 6px;
125
+ flex-shrink: 0;
126
+ }
127
+
128
+ #navigation-rail-search .ui.input input {
129
+ background: rgba(255,255,255,0.6);
130
+ border-color: #dddbd7;
131
+ border-radius: 20px;
132
+ font-size: 0.82rem;
133
+ }
134
+
135
+ #navigation-rail-views,
136
+ #navigation-rail-tags {
137
+ flex-shrink: 0;
138
+ }
139
+
140
+ #navigation-rail-tags {
141
+ flex: 1;
142
+ overflow-y: auto;
143
+ }
144
+
145
+ /* Rail menus — strip Fomantic box shadows / borders */
146
+ #navigation-rail .ui.vertical.menu {
147
+ width: 100%;
148
+ box-shadow: none;
149
+ border: none;
150
+ border-radius: 0;
151
+ background: transparent;
152
+ margin: 0;
153
+ }
154
+
155
+ #navigation-rail .ui.vertical.menu .item {
156
+ padding: 7px 14px;
157
+ font-size: 0.83rem;
158
+ color: #555;
159
+ border-radius: 0;
160
+ }
161
+
162
+ #navigation-rail .ui.vertical.menu .item.active {
163
+ background: rgba(255,255,255,0.55);
164
+ color: #1a1a1a;
165
+ font-weight: 600;
166
+ border-left: 3px solid #4a90d9;
167
+ }
168
+
169
+ #navigation-rail .ui.vertical.menu .item .icon {
170
+ margin-right: 8px;
171
+ color: #888;
172
+ width: 1em;
173
+ }
174
+
175
+ #navigation-rail .ui.vertical.menu .item.active .icon {
176
+ color: #4a90d9;
177
+ }
178
+
179
+ .rail-section-label {
180
+ padding: 14px 14px 4px;
181
+ font-size: 0.7rem;
182
+ font-weight: 700;
183
+ letter-spacing: 0.08em;
184
+ text-transform: uppercase;
185
+ color: #999;
186
+ }
187
+
188
+ /* ── Item list panel ────────────────────────────────── */
189
+ #item-list-panel {
190
+ display: flex;
191
+ flex-direction: column;
192
+ overflow: hidden;
193
+ }
194
+
195
+ #item-list-header {
196
+ padding: 12px 14px 8px;
197
+ flex-shrink: 0;
198
+ border-bottom: 1px solid #eae8e4;
199
+ }
200
+
201
+ #item-list-header .ui.header {
202
+ margin: 0;
203
+ font-size: 1rem;
204
+ font-weight: 600;
205
+ color: #1a1a1a;
206
+ }
207
+
208
+ #item-list-search {
209
+ flex-shrink: 0;
210
+ padding: 0 10px 8px;
211
+ }
212
+
213
+ #item-list-search .ui.input input {
214
+ background: #efecea;
215
+ border-color: transparent;
216
+ border-radius: 20px;
217
+ font-size: 0.82rem;
218
+ }
219
+
220
+ #item-list-banner {
221
+ flex-shrink: 0;
222
+ margin: 0 10px 6px;
223
+ }
224
+
225
+ #item-list-banner .ui.message {
226
+ font-size: 0.78rem;
227
+ padding: 8px 12px;
228
+ border-radius: 8px;
229
+ }
230
+
231
+ #item-list-scroll {
232
+ flex: 1;
233
+ overflow-y: auto;
234
+ padding: 4px 0;
235
+ }
236
+
237
+ /* Note cards */
238
+ .note-card {
239
+ padding: 10px 14px;
240
+ border-bottom: 1px solid #eae8e4;
241
+ cursor: pointer;
242
+ border-left: 3px solid transparent;
243
+ transition: background 0.12s;
244
+ }
245
+
246
+ .note-card:hover { background: #f3f1ee; }
247
+
248
+ .note-card.active {
249
+ background: #eaf0f9;
250
+ border-left-color: #4a90d9;
251
+ }
252
+
253
+ .note-card-title {
254
+ font-size: 0.88rem;
255
+ font-weight: 600;
256
+ color: #1a1a1a;
257
+ margin-bottom: 3px;
258
+ white-space: nowrap;
259
+ overflow: hidden;
260
+ text-overflow: ellipsis;
261
+ }
262
+
263
+ .note-card-preview {
264
+ font-size: 0.78rem;
265
+ color: #777;
266
+ white-space: nowrap;
267
+ overflow: hidden;
268
+ text-overflow: ellipsis;
269
+ margin-bottom: 4px;
270
+ }
271
+
272
+ .note-card-meta {
273
+ font-size: 0.72rem;
274
+ color: #aaa;
275
+ }
276
+
277
+ .note-card-tag {
278
+ display: inline-block;
279
+ background: #eae8e4;
280
+ color: #666;
281
+ border-radius: 4px;
282
+ padding: 1px 6px;
283
+ font-size: 0.7rem;
284
+ margin-top: 4px;
285
+ }
286
+
287
+ /* ── Content editor ─────────────────────────────────── */
288
+ #content-editor {
289
+ display: flex;
290
+ flex-direction: column;
291
+ flex-grow: 1;
292
+ overflow-y: hidden;
293
+ }
294
+
295
+ /* Toolbar row */
296
+ #editor-toolbar-row.ui.menu {
297
+ border-radius: 0;
298
+ border: none;
299
+ border-bottom: none !important;
300
+ box-shadow: none;
301
+ background: #fff;
302
+ margin: 0;
303
+ min-height: 46px;
304
+ padding: 0 6px;
305
+ }
306
+
307
+ #editor-toolbar-row .ui.transparent.input input {
308
+ font-size: 0.98rem;
309
+ font-weight: 600;
310
+ color: #1a1a1a;
311
+ padding-left: 4px;
312
+ }
313
+
314
+ #editor-toolbar-row .item {
315
+ padding: 6px 8px;
316
+ color: #888;
317
+ }
318
+
319
+ #editor-toolbar-row .item:hover { color: #333; }
320
+
321
+ /* Context strip */
322
+ #editor-context-strip.ui.menu {
323
+ border-radius: 0;
324
+ border: none;
325
+ border-bottom: 1px solid #eae8e4 !important;
326
+ box-shadow: none;
327
+ background: #fafafa;
328
+ margin: 0;
329
+ min-height: 36px;
330
+ padding: 0 10px;
331
+ }
332
+
333
+ #editor-context-strip .item {
334
+ padding: 4px 6px;
335
+ }
336
+
337
+ #editor-context-strip .ui.label {
338
+ font-size: 0.73rem;
339
+ border-radius: 20px;
340
+ padding: 3px 10px;
341
+ background: #eae8e4;
342
+ color: #555;
343
+ border: none;
344
+ margin-right: 4px;
345
+ }
346
+
347
+ #editor-context-strip .ui.transparent.input input {
348
+ font-size: 0.78rem;
349
+ color: #aaa;
350
+ padding: 0;
351
+ }
352
+
353
+ /* Formatting toolbar */
354
+ #editor-format-bar.ui.menu {
355
+ border-radius: 0;
356
+ border: none;
357
+ border-bottom: 1px solid #eae8e4 !important;
358
+ box-shadow: none;
359
+ background: #f8f7f5;
360
+ margin: 0;
361
+ min-height: 34px;
362
+ }
363
+
364
+ #editor-format-bar .item {
365
+ padding: 5px 10px;
366
+ color: #777;
367
+ font-size: 0.82rem;
368
+ }
369
+
370
+ #editor-format-bar .item:hover { background: #eee; color: #222; }
371
+ #editor-format-bar .item.active { color: #4a90d9; }
372
+
373
+ #editor-format-bar .divider {
374
+ width: 1px;
375
+ background: #ddd;
376
+ margin: 6px 2px;
377
+ align-self: stretch;
378
+ }
379
+
380
+ /* Body canvas */
381
+ #editor-body-canvas {
382
+ flex: 1;
383
+ overflow-y: auto;
384
+ padding: 28px 40px;
385
+ }
386
+
387
+ #editor-body-canvas textarea {
388
+ width: 100%;
389
+ height: 100%;
390
+ border: none;
391
+ outline: none;
392
+ resize: none;
393
+ font-size: 0.95rem;
394
+ line-height: 1.75;
395
+ color: #2a2a2a;
396
+ font-family: 'Georgia', serif;
397
+ background: transparent;
398
+ }
399
+
400
+ /* ── System bar ─────────────────────────────────────── */
401
+ #system-bar {
402
+ display: flex;
403
+ align-items: center;
404
+ justify-content: space-between;
405
+ padding: 0 12px;
406
+ font-size: 0.72rem;
407
+ color: #888;
408
+ }
409
+
410
+ #system-bar-left,
411
+ #system-bar-right {
412
+ display: flex;
413
+ align-items: center;
414
+ gap: 12px;
415
+ }
416
+
417
+ #system-bar .sync-dot {
418
+ width: 6px;
419
+ height: 6px;
420
+ border-radius: 50%;
421
+ background: #5cb85c;
422
+ display: inline-block;
423
+ margin-right: 4px;
424
+ }
425
+
426
+ #system-bar a {
427
+ color: #4a90d9;
428
+ text-decoration: none;
429
+ font-weight: 500;
430
+ }
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MilkdownEngine
4
- class ApplicationController < ActionController::Base
4
+ class ApplicationController < ::ApplicationController
5
5
  end
6
6
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkdownEngine
4
+ class NotesController < ApplicationController
5
+ before_action :set_notes
6
+ before_action :set_note, only: %i[show edit update destroy]
7
+
8
+ def index
9
+ @note = @notes.first || Document.new
10
+ end
11
+
12
+ def show; end
13
+
14
+ def new
15
+ @note = Document.new
16
+ end
17
+
18
+ def create
19
+ @note = Document.new(note_params)
20
+ if @note.save
21
+ redirect_to note_path(@note), notice: "Note was successfully created."
22
+ else
23
+ render :new, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ def edit; end
28
+
29
+ def update
30
+ if @note.update(note_params)
31
+ respond_to do |format|
32
+ format.turbo_stream { head :ok }
33
+ format.html { redirect_to note_path(@note), notice: "Note was successfully updated." }
34
+ end
35
+ else
36
+ render :edit, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def destroy
41
+ @note.destroy!
42
+ redirect_to notes_path, notice: "Note was successfully deleted."
43
+ end
44
+
45
+ private
46
+
47
+ def set_notes
48
+ @q = Document.ransack(params[:q])
49
+ @notes = @q.result.order(updated_at: :desc)
50
+ end
51
+
52
+ def set_note
53
+ @note = Document.find(params[:id])
54
+ end
55
+
56
+ def note_params
57
+ params.require(:document).permit(:title, :content).tap do |p|
58
+ p[:content] = JSON.parse(p[:content]) if p[:content].is_a?(String)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -3,7 +3,7 @@
3
3
  module MilkdownEngine
4
4
  module ApplicationHelper
5
5
  CONTROLLER_ID = "milkdown-engine--editor"
6
- EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] }.to_json.freeze
6
+ EMPTY_DOC = { type: "doc", content: [ { type: "paragraph" } ] }.to_json.freeze
7
7
 
8
8
  # Renders a Milkdown editor wired to a hidden <input> for form submission.
9
9
  #
@@ -24,12 +24,12 @@ module MilkdownEngine
24
24
  # ==== Examples
25
25
  #
26
26
  # # Standalone (no form builder)
27
- # <%= milkdown_editor "md_document[content]", @document.content %>
27
+ # <%= milkdown_editor "document[content]", @document.content %>
28
28
  #
29
29
  # # Inside a Fomantic-UI form
30
30
  # <% Form { %>
31
31
  # <div class="field">
32
- # <%= milkdown_editor "md_document[content]", @document.content, label: "Content" %>
32
+ # <%= milkdown_editor "document[content]", @document.content, label: "Content" %>
33
33
  # </div>
34
34
  # <% Button(variant: :primary, type: :submit) { text "Save" } %>
35
35
  # <% } %>
@@ -43,11 +43,11 @@ module MilkdownEngine
43
43
  editor_html = html_options.delete(:editor_html) || {}
44
44
 
45
45
  json_value = case value
46
- when String then value.presence || EMPTY_DOC
47
- when Hash then value.present? ? value.to_json : EMPTY_DOC
48
- when nil then EMPTY_DOC
49
- else value.to_json
50
- end
46
+ when String then value.presence || EMPTY_DOC
47
+ when Hash then value.present? ? value.to_json : EMPTY_DOC
48
+ when nil then EMPTY_DOC
49
+ else value.to_json
50
+ end
51
51
 
52
52
  extra_class = html_options.delete(:class)
53
53
  wrapper_classes = class_names("ui segment milkdown-editor", extra_class)
@@ -84,5 +84,16 @@ module MilkdownEngine
84
84
 
85
85
  milkdown_editor(name, value, **options)
86
86
  end
87
+
88
+ # PascalCase helper for use inside Form() blocks.
89
+ # Uses @_form_builder set by FormComponent automatically.
90
+ #
91
+ # Form(model: @note, url: note_path(@note)) {
92
+ # MilkdownEditor(:content)
93
+ # }
94
+ #
95
+ def MilkdownEditor(method, **options)
96
+ output_buffer << milkdown_editor_field(@_form_builder, method, **options)
97
+ end
87
98
  end
88
99
  end
@@ -0,0 +1,10 @@
1
+ import AutoSubmit from "@stimulus-components/auto-submit"
2
+
3
+ export default class extends AutoSubmit {
4
+ static values = {
5
+ delay: {
6
+ type: Number,
7
+ default: 500,
8
+ },
9
+ }
10
+ }
@@ -13,7 +13,7 @@ import {
13
13
  // data-milkdown-engine--editor-readonly-value="false">
14
14
  // <input type="hidden"
15
15
  // data-milkdown-engine--editor-target="input"
16
- // name="md_document[content]"
16
+ // name="document[content]"
17
17
  // value='{"type":"doc","content":[]}'>
18
18
  // <div data-milkdown-engine--editor-target="editor"></div>
19
19
  // </div>
@@ -0,0 +1,29 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["item"]
5
+ static values = { selectedId: { type: Number, default: 0 } }
6
+
7
+ connect() {
8
+ this.#applySelected()
9
+ }
10
+
11
+ select(event) {
12
+ const card = event.currentTarget.querySelector("[data-note-id]")
13
+ || event.target.closest("[data-note-id]")
14
+ if (card) {
15
+ this.selectedIdValue = parseInt(card.dataset.noteId, 10) || 0
16
+ }
17
+ }
18
+
19
+ selectedIdValueChanged() {
20
+ this.#applySelected()
21
+ }
22
+
23
+ #applySelected() {
24
+ this.itemTargets.forEach((item) => {
25
+ const id = parseInt(item.dataset.noteId, 10) || 0
26
+ item.classList.toggle("active", id === this.selectedIdValue)
27
+ })
28
+ }
29
+ }
@@ -1,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MilkdownEngine
4
- class MdDocument < ApplicationRecord
4
+ class Document < ApplicationRecord
5
5
  ChecklistItem = Data.define(:text, :checked)
6
6
  Heading = Data.define(:level, :id, :text)
7
7
 
8
+ acts_as_taggable_on :tags
9
+
8
10
  validates :content, presence: true
9
11
 
12
+ def self.ransackable_attributes(auth_object = nil)
13
+ %w[title created_at updated_at]
14
+ end
15
+
16
+ def self.ransackable_associations(auth_object = nil)
17
+ %w[tags taggings]
18
+ end
19
+
10
20
  before_save :set_title_from_content, if: -> { title.blank? && content_changed? }
11
21
 
12
22
  # ── JSON query helpers (PostgreSQL jsonb_path_query) ─────────────────
@@ -24,7 +34,7 @@ module MilkdownEngine
24
34
  (item #>> '{attrs,checked}')::boolean AS checked,
25
35
  string_agg(text_node #>> '{text}', '' ORDER BY text_ord) AS item_text
26
36
  FROM jsonb_path_query(
27
- (SELECT content FROM milkdown_engine_md_documents WHERE id = :id),
37
+ (SELECT content FROM milkdown_engine_documents WHERE id = :id),
28
38
  'strict $.** ? (@.type == "list_item" && @.attrs.checked != null)'
29
39
  ) WITH ORDINALITY AS items(item, item_ord),
30
40
  jsonb_path_query(item, 'strict $.** ? (@.type == "text")') WITH ORDINALITY AS texts(text_node, text_ord)
@@ -46,7 +56,7 @@ module MilkdownEngine
46
56
  heading #>> '{attrs,id}' AS id,
47
57
  string_agg(text_node #>> '{text}', '' ORDER BY text_ord) AS heading_text
48
58
  FROM jsonb_path_query(
49
- (SELECT content FROM milkdown_engine_md_documents WHERE id = :id),
59
+ (SELECT content FROM milkdown_engine_documents WHERE id = :id),
50
60
  'strict $.** ? (@.type == "heading")'
51
61
  ) WITH ORDINALITY AS headings(heading, heading_ord),
52
62
  jsonb_path_query(heading, 'strict $.** ? (@.type == "text")') WITH ORDINALITY AS texts(text_node, text_ord)
@@ -69,8 +79,10 @@ module MilkdownEngine
69
79
  #
70
80
  # Instead we interpolate the quoted id via +:id+ and +connection.quote+.
71
81
  def exec_jsonb_query(sql)
72
- quoted = sql.gsub(":id", self.class.connection.quote(id))
73
- self.class.connection.select_all(quoted)
82
+ self.class.with_connection do |conn|
83
+ quoted = sql.gsub(":id", conn.quote(id))
84
+ conn.select_all(quoted)
85
+ end
74
86
  end
75
87
 
76
88
  # Walk the ProseMirror JSON tree in Ruby to find the first heading's text.
@@ -0,0 +1,3 @@
1
+ Wrapper(id: "content-editor") {
2
+ text yield
3
+ }
@@ -0,0 +1,3 @@
1
+ Form(model: note, url: note.persisted? ? note_path(note) : notes_path, data: { controller: "milkdown-engine--auto-submit", action: "milkdown-engine--editor:change->milkdown-engine--auto-submit#submit" }) {
2
+ MilkdownEditor(:content)
3
+ }
@@ -0,0 +1,27 @@
1
+ output_buffer << form_with(model: note, url: note.persisted? ? note_path(note) : notes_path, html: { class: "ui form" }) { |f|
2
+ if note.errors.any?
3
+ Message(type: :error) { |c|
4
+ c.header { text pluralize(note.errors.count, "error") + " prohibited this note from being saved" }
5
+ note.errors.full_messages.each { |msg| output_buffer << tag.p(msg) }
6
+ }
7
+ end
8
+
9
+ output_buffer << tag.div(class: "field") {
10
+ safe_join([
11
+ f.label(:title),
12
+ tag.input(type: "text", name: "document[title]", value: note.title, placeholder: "Auto-filled from first heading if blank")
13
+ ])
14
+ }
15
+
16
+ output_buffer << tag.div(class: "field") {
17
+ safe_join([
18
+ tag.label("Content"),
19
+ milkdown_editor_field(f, :content)
20
+ ])
21
+ }
22
+
23
+ Divider(hidden: true)
24
+ Button(variant: :primary, type: :submit, icon: "save") {
25
+ text note.persisted? ? "Update note" : "Create note"
26
+ }
27
+ }
@@ -0,0 +1,22 @@
1
+ Wrapper(id: "item-list-panel", data: { turbo_permanent: true, controller: "milkdown-engine--item-list", "milkdown-engine--item-list-selected-id-value": (defined?(@note) && @note&.persisted? ? @note.id : 0) }) {
2
+ Wrapper(id: "item-list-header") {
3
+ HStack(spacing: 8, justify: "between", align: "center") {
4
+ Header(size: :h3) { text "Notes" }
5
+ Button(href: new_note_path, size: :mini, icon: "plus", variant: :primary)
6
+ }
7
+ }
8
+
9
+ Wrapper(id: "item-list-search") {
10
+ output_buffer << search_form_for(@q, url: notes_path, html: { class: "ui form", data: { turbo_frame: "notes-list", controller: "milkdown-engine--auto-submit", action: "input->milkdown-engine--auto-submit#submit" } }) { |f|
11
+ Input(fluid: true, icon: "search", placeholder: "Search…", name: "q[title_cont]", value: params.dig(:q, :title_cont))
12
+ }
13
+ }
14
+
15
+ output_buffer << turbo_frame_tag("notes-list") {
16
+ Wrapper(id: "item-list-scroll") {
17
+ @notes.each do |note|
18
+ Partial("note_card", note: note)
19
+ end
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,30 @@
1
+ Wrapper(id: "navigation-rail") {
2
+ Wrapper(id: "navigation-rail-search") {
3
+ Input(fluid: true, icon: "search", placeholder: "Search tags…")
4
+ }
5
+
6
+ Wrapper(html_class: "rail-section-label") { text "Views" }
7
+
8
+ Wrapper(id: "navigation-rail-views") {
9
+ Menu(vertical: true) {
10
+ MenuItem(active: true, icon: "list") { text "Notes" }
11
+ MenuItem(icon: "folder") { text "Files" }
12
+ MenuItem(icon: "star") { text "Starred" }
13
+ MenuItem(icon: "archive") { text "Archived" }
14
+ MenuItem(icon: "trash") { text "Trash" }
15
+ MenuItem(icon: "tag") { text "Untagged" }
16
+ }
17
+ }
18
+
19
+ Wrapper(html_class: "rail-section-label") { text "Tags" }
20
+
21
+ Wrapper(id: "navigation-rail-tags") {
22
+ Menu(vertical: true) {
23
+ MenuItem(icon: "hashtag") { text "construction" }
24
+ MenuItem(icon: "hashtag") { text "clients" }
25
+ MenuItem(icon: "hashtag") { text "invoices" }
26
+ MenuItem(icon: "hashtag") { text "site-visits" }
27
+ MenuItem(icon: "hashtag") { text "materials" }
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,9 @@
1
+ Link(href: edit_note_path(note), data: { turbo_frame: "_top", action: "click->milkdown-engine--item-list#select" }) {
2
+ Wrapper(html_class: "note-card", data: { "milkdown-engine--item-list-target": "item", note_id: note.id }) {
3
+ Wrapper(html_class: "note-card-title") { text note.title.presence || "Untitled" }
4
+ Wrapper(html_class: "note-card-meta") { text note.updated_at.strftime("%A, %d %b %Y") }
5
+ note.tag_list.each do |t|
6
+ output_buffer << tag.span(t, class: "note-card-tag")
7
+ end
8
+ }
9
+ }
@@ -0,0 +1,3 @@
1
+ Form(model: note, url: note.persisted? ? note_path(note) : notes_path, data: { controller: "milkdown-engine--auto-submit", action: "input->milkdown-engine--auto-submit#submit" }) {
2
+ Input(transparent: true, fluid: true, name: "document[title]", value: note.title, placeholder: "Untitled")
3
+ }
@@ -0,0 +1,11 @@
1
+ Wrapper(id: "md-app") {
2
+ Partial("milkdown_engine/notes/item_list_panel")
3
+ Partial("milkdown_engine/notes/content_editor") {
4
+ Wrapper(html_class: "editor-title-bar") {
5
+ Partial("title_form", note: @note)
6
+ }
7
+ Wrapper(html_class: "editor-content") {
8
+ Partial("content_form", note: @note)
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,11 @@
1
+ Wrapper(id: "md-app") {
2
+ Partial("milkdown_engine/notes/item_list_panel")
3
+ Partial("milkdown_engine/notes/content_editor") {
4
+ Wrapper(html_class: "editor-title-bar") {
5
+ Partial("title_form", note: @note)
6
+ }
7
+ Wrapper(html_class: "editor-content") {
8
+ Partial("content_form", note: @note)
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,11 @@
1
+ Wrapper(id: "md-app") {
2
+ Partial("milkdown_engine/notes/item_list_panel")
3
+ Partial("milkdown_engine/notes/content_editor") {
4
+ Wrapper(html_class: "editor-title-bar") {
5
+ Partial("title_form", note: @note)
6
+ }
7
+ Wrapper(html_class: "editor-content") {
8
+ Partial("content_form", note: @note)
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,11 @@
1
+ Wrapper(id: "md-app") {
2
+ Partial("milkdown_engine/notes/item_list_panel")
3
+ Partial("milkdown_engine/notes/content_editor") {
4
+ Wrapper(html_class: "editor-title-bar") {
5
+ Partial("title_form", note: @note)
6
+ }
7
+ Wrapper(html_class: "editor-content") {
8
+ Partial("content_form", note: @note)
9
+ }
10
+ }
11
+ }
data/config/importmap.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  # Vendored Milkdown bundle (built by esbuild from src/)
4
4
  pin "milkdown_engine/milkdown", to: "milkdown_engine/milkdown.min.js"
5
5
 
6
+ # Vendored dependencies
7
+ pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js"
8
+
6
9
  # MilkdownEngine Stimulus controllers
7
10
  pin_all_from MilkdownEngine::Engine.root.join("app/javascript/milkdown_engine/controllers"),
8
11
  under: "controllers/milkdown_engine", to: "milkdown_engine/controllers"
data/config/routes.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  MilkdownEngine::Engine.routes.draw do
4
+ resources :notes
4
5
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Consolidated migration for acts-as-taggable-on (v13).
4
+ # Creates both `tags` and `taggings` tables with all required indexes.
5
+
6
+ class CreateActsAsTaggableOnTables < ActiveRecord::Migration[8.1]
7
+ def change
8
+ create_table :tags do |t|
9
+ t.string :name, null: false
10
+ t.integer :taggings_count, default: 0
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :tags, :name, unique: true
15
+
16
+ create_table :taggings do |t|
17
+ t.references :tag, null: false, foreign_key: true
18
+ t.references :taggable, polymorphic: true
19
+ t.references :tagger, polymorphic: true
20
+ t.string :context, limit: 128
21
+ t.datetime :created_at
22
+ end
23
+
24
+ add_index :taggings,
25
+ %i[tag_id taggable_id taggable_type context tagger_id tagger_type],
26
+ unique: true, name: "taggings_idx"
27
+
28
+ add_index :taggings,
29
+ %i[taggable_id taggable_type context],
30
+ name: "taggings_taggable_context_idx"
31
+
32
+ add_index :taggings, %i[tagger_id tagger_type]
33
+
34
+ add_index :taggings,
35
+ %i[taggable_id taggable_type tagger_id context],
36
+ name: "taggings_idy"
37
+
38
+ add_index :taggings, :context
39
+ end
40
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RenameMdDocumentsToDocuments < ActiveRecord::Migration[8.1]
4
+ def change
5
+ rename_table :milkdown_engine_md_documents, :milkdown_engine_documents
6
+ end
7
+ end
@@ -6,6 +6,11 @@ module MilkdownEngine
6
6
  class Engine < ::Rails::Engine
7
7
  isolate_namespace MilkdownEngine
8
8
 
9
+ initializer "milkdown_engine.dependencies" do
10
+ require "acts-as-taggable-on"
11
+ require "ransack"
12
+ end
13
+
9
14
  # Register importmap pins for Stimulus controllers
10
15
  initializer "milkdown_engine.importmap", before: "importmap" do |app|
11
16
  if app.config.respond_to?(:importmap)
@@ -23,6 +28,9 @@ module MilkdownEngine
23
28
  # importmap pin_all_from URLs resolve through the asset pipeline.
24
29
  app.config.assets.paths << Engine.root.join("app/javascript")
25
30
 
31
+ # Vendored JS packages
32
+ app.config.assets.paths << Engine.root.join("vendor/javascript")
33
+
26
34
  # rails-active-ui ships stylesheets.css directly in app/assets/
27
35
  app.config.assets.paths << Ui::Engine.root.join("app/assets")
28
36
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MilkdownEngine
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: milkdown_engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Kidd
@@ -65,6 +65,34 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: acts-as-taggable-on
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ransack
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
68
96
  description: Provides a Milkdown-based rich markdown editor as a Rails engine with
69
97
  Stimulus controllers, importmap integration, and rails-active-ui components.
70
98
  email:
@@ -140,13 +168,29 @@ files:
140
168
  - app/assets/stylesheets/milkdown_engine/fonts/KaTeX_Typewriter-Regular.woff2
141
169
  - app/assets/stylesheets/milkdown_engine/milkdown.css
142
170
  - app/controllers/milkdown_engine/application_controller.rb
171
+ - app/controllers/milkdown_engine/notes_controller.rb
143
172
  - app/helpers/milkdown_engine/application_helper.rb
173
+ - app/javascript/milkdown_engine/controllers/auto_submit_controller.js
144
174
  - app/javascript/milkdown_engine/controllers/editor_controller.js
175
+ - app/javascript/milkdown_engine/controllers/item_list_controller.js
145
176
  - app/models/milkdown_engine/application_record.rb
146
- - app/models/milkdown_engine/md_document.rb
177
+ - app/models/milkdown_engine/document.rb
178
+ - app/views/milkdown_engine/notes/_content_editor.html.ruby
179
+ - app/views/milkdown_engine/notes/_content_form.html.ruby
180
+ - app/views/milkdown_engine/notes/_form.html.ruby
181
+ - app/views/milkdown_engine/notes/_item_list_panel.html.ruby
182
+ - app/views/milkdown_engine/notes/_navigation_rail.html.ruby
183
+ - app/views/milkdown_engine/notes/_note_card.html.ruby
184
+ - app/views/milkdown_engine/notes/_title_form.html.ruby
185
+ - app/views/milkdown_engine/notes/edit.html.ruby
186
+ - app/views/milkdown_engine/notes/index.html.ruby
187
+ - app/views/milkdown_engine/notes/new.html.ruby
188
+ - app/views/milkdown_engine/notes/show.html.ruby
147
189
  - config/importmap.rb
148
190
  - config/routes.rb
149
191
  - db/migrate/20260325000000_create_milkdown_engine_md_documents.rb
192
+ - db/migrate/20260327000000_create_acts_as_taggable_on_tables.rb
193
+ - db/migrate/20260327100000_rename_md_documents_to_documents.rb
150
194
  - lib/milkdown_engine.rb
151
195
  - lib/milkdown_engine/engine.rb
152
196
  - lib/milkdown_engine/version.rb