trek 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. checksums.yaml +4 -4
  2. data/.node-version +1 -1
  3. data/AGENTS.md +7 -4
  4. data/CHANGELOG.md +37 -0
  5. data/Gemfile +5 -0
  6. data/Gemfile.lock +16 -3
  7. data/Makefile +2 -0
  8. data/app/components/trek/dashboard/tile_component/tile_component.css +9 -0
  9. data/app/components/trek/dashboard/tile_component/tile_component.html.slim +4 -2
  10. data/app/components/trek/dashboard/tile_component.rb +12 -2
  11. data/app/components/trek/form/content_editor_component/content_editor_component.css +5 -0
  12. data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +19 -93
  13. data/app/components/trek/form/content_editor_component/content_editor_component.js +94 -251
  14. data/app/components/trek/form/content_editor_component/extensions/block_view.js +40 -0
  15. data/app/components/trek/form/content_editor_component/extensions/image_block.js +28 -26
  16. data/app/components/trek/form/content_editor_component/extensions/prompts_block.js +15 -26
  17. data/app/components/trek/form/content_editor_component/extensions/table_of_contents.js +1 -3
  18. data/app/components/trek/form/content_editor_component.rb +15 -0
  19. data/app/components/trek/form/image_field_component/image_field_component.css +40 -1
  20. data/app/components/trek/form/image_field_component/image_field_component.html.slim +0 -9
  21. data/app/components/trek/form/image_field_component.rb +36 -9
  22. data/app/content/trek/content/cleaner.rb +53 -0
  23. data/app/content/trek/content/document.rb +2 -2
  24. data/app/content/trek/content/prompts_block.rb +1 -2
  25. data/app/content/trek/content.rb +6 -0
  26. data/app/controllers/concerns/trek/reorderable.rb +24 -6
  27. data/app/controllers/trek/csp_reports_controller.rb +14 -1
  28. data/app/controllers/trek/panels/prompts_controller.rb +1 -1
  29. data/app/controllers/trek/resource_controller.rb +5 -5
  30. data/app/jobs/trek/purge_expired_sessions_job.rb +4 -3
  31. data/app/models/concerns/trek/contentable.rb +15 -1
  32. data/app/models/concerns/trek/users/sign_in_code.rb +70 -0
  33. data/app/models/concerns/trek/users/sign_in_lockout.rb +4 -2
  34. data/app/views/trek/panels/links/_form.html.slim +2 -4
  35. data/app/views/trek/panels/prompts/create.turbo_stream.slim +0 -1
  36. data/config/locales/trek.en.yml +26 -11
  37. data/config/locales/trek.fr.yml +26 -11
  38. data/docs/.vitepress/config.mjs +4 -1
  39. data/docs/background-jobs.md +69 -0
  40. data/docs/content-editor.md +219 -0
  41. data/docs/customize.md +2 -0
  42. data/docs/deploy.md +2 -2
  43. data/docs/getting-started.md +1 -1
  44. data/docs/index.md +1 -1
  45. data/docs/reference/components.md +2 -2
  46. data/docs/reference/concerns/contentable.md +10 -4
  47. data/docs/reference/concerns/formattable.md +1 -1
  48. data/docs/reference/environment-variables.md +1 -0
  49. data/docs/reference/formatters.md +2 -30
  50. data/docs/reference/forms.md +1 -1
  51. data/docs/reference/generators/audit-log.md +2 -2
  52. data/docs/reference/generators/install.md +3 -2
  53. data/docs/reference/generators/public-accounts.md +141 -0
  54. data/docs/reference/generators/security-headers.md +1 -1
  55. data/docs/reference/index.md +2 -1
  56. data/docs/reference/models/user.md +3 -1
  57. data/docs/what-is-trek.md +1 -1
  58. data/lib/generators/trek/audit_log_generator.rb +54 -0
  59. data/lib/generators/trek/install/admin/users_generator.rb +6 -1
  60. data/lib/generators/trek/install/audit_log_generator.rb +9 -45
  61. data/lib/generators/trek/install/good_job_generator.rb +161 -0
  62. data/lib/generators/trek/install/model_specs_generator.rb +3 -3
  63. data/lib/generators/trek/install/ruby_generator.rb +1 -1
  64. data/lib/generators/trek/install/scalingo_generator.rb +7 -45
  65. data/lib/generators/trek/install_generator.rb +15 -0
  66. data/lib/generators/trek/public_accounts_generator.rb +267 -0
  67. data/lib/generators/trek/scalingo_generator.rb +55 -0
  68. data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
  69. data/lib/generators/trek/templates/controllers/admin/password_renewals_controller.rb +11 -0
  70. data/lib/generators/trek/templates/controllers/admin/user_sessions_controller.rb +1 -1
  71. data/lib/generators/trek/templates/controllers/admin/users_controller.rb +0 -8
  72. data/lib/generators/trek/templates/initializers/content_security_policy.rb +3 -2
  73. data/lib/generators/trek/templates/initializers/good_job.rb.tt +38 -0
  74. data/lib/generators/trek/templates/jobs/mail_delivery_job.rb +6 -0
  75. data/lib/generators/trek/templates/layouts/admin.html.slim +5 -0
  76. data/lib/generators/trek/templates/mailers/admin_mailer.rb +4 -2
  77. data/lib/generators/trek/templates/package.json.tt +1 -1
  78. data/lib/generators/trek/templates/public_accounts/api/authentication.rb.tt +76 -0
  79. data/lib/generators/trek/templates/public_accounts/api/controllers/accounts_controller.rb.tt +22 -0
  80. data/lib/generators/trek/templates/public_accounts/api/controllers/active_sessions_controller.rb.tt +21 -0
  81. data/lib/generators/trek/templates/public_accounts/api/controllers/base_controller.rb.tt +71 -0
  82. data/lib/generators/trek/templates/public_accounts/api/controllers/email_changes_controller.rb.tt +55 -0
  83. data/lib/generators/trek/templates/public_accounts/api/controllers/email_confirmations_controller.rb.tt +19 -0
  84. data/lib/generators/trek/templates/public_accounts/api/controllers/password_resets_controller.rb.tt +28 -0
  85. data/lib/generators/trek/templates/public_accounts/api/controllers/passwords_controller.rb.tt +17 -0
  86. data/lib/generators/trek/templates/public_accounts/api/controllers/registrations_controller.rb.tt +17 -0
  87. data/lib/generators/trek/templates/public_accounts/api/controllers/sessions_controller.rb.tt +45 -0
  88. data/lib/generators/trek/templates/public_accounts/api/controllers/sign_in_codes_controller.rb.tt +15 -0
  89. data/lib/generators/trek/templates/public_accounts/jobs/sign_in_code_job.rb.tt +13 -0
  90. data/lib/generators/trek/templates/public_accounts/locales/accounts.en.yml.tt +205 -0
  91. data/lib/generators/trek/templates/public_accounts/locales/accounts.fr.yml.tt +205 -0
  92. data/lib/generators/trek/templates/public_accounts/mailers/account_mailer.rb.tt +85 -0
  93. data/lib/generators/trek/templates/public_accounts/migrations/create_accounts.rb.tt +46 -0
  94. data/lib/generators/trek/templates/public_accounts/models/account/session.rb.tt +47 -0
  95. data/lib/generators/trek/templates/public_accounts/models/account/sign_in_code.rb.tt +8 -0
  96. data/lib/generators/trek/templates/public_accounts/models/account.rb.tt +73 -0
  97. data/lib/generators/trek/templates/public_accounts/spec/api/accounts_spec.rb.tt +90 -0
  98. data/lib/generators/trek/templates/public_accounts/spec/api/sign_ins_spec.rb.tt +158 -0
  99. data/lib/generators/trek/templates/public_accounts/spec/models/account/sign_in_code_spec.rb.tt +56 -0
  100. data/lib/generators/trek/templates/public_accounts/spec/models/account_spec.rb.tt +109 -0
  101. data/lib/generators/trek/templates/public_accounts/spec/web/accounts_spec.rb.tt +126 -0
  102. data/lib/generators/trek/templates/public_accounts/spec/web/authentication_spec.rb.tt +43 -0
  103. data/lib/generators/trek/templates/public_accounts/spec/web/sign_ins_spec.rb.tt +215 -0
  104. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.html.slim.tt +10 -0
  105. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.en.text.erb.tt +8 -0
  106. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.html.slim.tt +13 -0
  107. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change.fr.text.erb.tt +8 -0
  108. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.html.slim.tt +9 -0
  109. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.en.text.erb.tt +11 -0
  110. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.html.slim.tt +11 -0
  111. data/lib/generators/trek/templates/public_accounts/views/mailer/email_change_code.fr.text.erb.tt +11 -0
  112. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.html.slim.tt +10 -0
  113. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.en.text.erb.tt +8 -0
  114. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.html.slim.tt +10 -0
  115. data/lib/generators/trek/templates/public_accounts/views/mailer/email_confirmation.fr.text.erb.tt +8 -0
  116. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.html.slim.tt +10 -0
  117. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.en.text.erb.tt +8 -0
  118. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.html.slim.tt +13 -0
  119. data/lib/generators/trek/templates/public_accounts/views/mailer/password_reset.fr.text.erb.tt +8 -0
  120. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.html.slim.tt +9 -0
  121. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.en.text.erb.tt +11 -0
  122. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.html.slim.tt +9 -0
  123. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_code.fr.text.erb.tt +11 -0
  124. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.html.slim.tt +23 -0
  125. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.en.text.erb.tt +14 -0
  126. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.html.slim.tt +24 -0
  127. data/lib/generators/trek/templates/public_accounts/views/mailer/sign_in_locked.fr.text.erb.tt +14 -0
  128. data/lib/generators/trek/templates/public_accounts/web/authentication.rb.tt +97 -0
  129. data/lib/generators/trek/templates/public_accounts/web/controllers/accounts_controller.rb.tt +33 -0
  130. data/lib/generators/trek/templates/public_accounts/web/controllers/active_sessions_controller.rb.tt +15 -0
  131. data/lib/generators/trek/templates/public_accounts/web/controllers/base_controller.rb.tt +52 -0
  132. data/lib/generators/trek/templates/public_accounts/web/controllers/email_changes_controller.rb.tt +85 -0
  133. data/lib/generators/trek/templates/public_accounts/web/controllers/email_confirmations_controller.rb.tt +19 -0
  134. data/lib/generators/trek/templates/public_accounts/web/controllers/password_resets_controller.rb.tt +41 -0
  135. data/lib/generators/trek/templates/public_accounts/web/controllers/passwords_controller.rb.tt +23 -0
  136. data/lib/generators/trek/templates/public_accounts/web/controllers/registrations_controller.rb.tt +24 -0
  137. data/lib/generators/trek/templates/public_accounts/web/controllers/sessions_controller.rb.tt +78 -0
  138. data/lib/generators/trek/templates/public_accounts/web/controllers/sign_in_codes_controller.rb.tt +27 -0
  139. data/lib/generators/trek/templates/public_accounts/web/views/_flash.html.slim.tt +5 -0
  140. data/lib/generators/trek/templates/public_accounts/web/views/accounts/show.html.slim.tt +52 -0
  141. data/lib/generators/trek/templates/public_accounts/web/views/email_changes/edit.html.slim.tt +17 -0
  142. data/lib/generators/trek/templates/public_accounts/web/views/email_changes/new.html.slim.tt +15 -0
  143. data/lib/generators/trek/templates/public_accounts/web/views/password_resets/edit.html.slim.tt +26 -0
  144. data/lib/generators/trek/templates/public_accounts/web/views/password_resets/new.html.slim.tt +15 -0
  145. data/lib/generators/trek/templates/public_accounts/web/views/passwords/edit.html.slim.tt +25 -0
  146. data/lib/generators/trek/templates/public_accounts/web/views/registrations/new.html.slim.tt +28 -0
  147. data/lib/generators/trek/templates/public_accounts/web/views/sessions/new.html.slim.tt +38 -0
  148. data/lib/generators/trek/templates/public_accounts/web/views/sign_in_codes/new.html.slim.tt +17 -0
  149. data/lib/generators/trek/templates/spec/jobs/mail_delivery_job_spec.rb +11 -0
  150. data/lib/generators/trek/templates/spec/mailers/admin_mailer_spec.rb +10 -0
  151. data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +1 -1
  152. data/lib/generators/trek/templates/spec/requests/admin/good_job_spec.rb +34 -0
  153. data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +1 -1
  154. data/lib/generators/trek/templates/spec/requests/admin/panels_spec.rb +16 -0
  155. data/lib/generators/trek/templates/spec/requests/admin/password_renewal_spec.rb +2 -2
  156. data/lib/generators/trek/templates/spec/requests/admin/sign_in_lockout_spec.rb +1 -1
  157. data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +2 -2
  158. data/lib/generators/trek/templates/views/admin/users/index.html.slim +1 -1
  159. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.html.slim +1 -1
  160. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.en.text.erb +1 -1
  161. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.html.slim +1 -1
  162. data/lib/generators/trek/templates/views/admin_mailer/new_sign_in.fr.text.erb +1 -1
  163. data/lib/trek/csp_reports.rb +22 -4
  164. data/lib/trek/typography_formatter.rb +35 -4
  165. data/lib/trek/version.rb +1 -1
  166. data/package.json +12 -8
  167. data/trek.gemspec +1 -1
  168. data/vitest.config.mjs +24 -0
  169. data/yarn.lock +1004 -288
  170. metadata +90 -3
@@ -1,20 +1,21 @@
1
1
  import "./content_editor_component.css";
2
2
  import { Editor } from "@tiptap/core";
3
3
  import FloatingMenu from "@tiptap/extension-floating-menu";
4
- import Link from "@tiptap/extension-link";
5
- // import Placeholder from '@tiptap/extension-placeholder';
6
4
  import StarterKit from "@tiptap/starter-kit";
7
5
  import { Controller as BaseController } from "@hotwired/stimulus";
8
6
  import { TableOfContents } from "./extensions/table_of_contents.js";
9
7
  import PromptsBlock from "./extensions/prompts_block.js";
10
8
  import ImageBlock from "./extensions/image_block.js";
11
9
 
10
+ // The content editor (Trek::Form::ContentEditorComponent): Tiptap, its toolbar, and the panels inserting
11
+ // links, images and calls to action. The document is kept in the form's field as JSON.
12
12
  export class Controller extends BaseController {
13
13
  static classes = ["active", "empty"];
14
14
  static targets = [
15
15
  "editorContent",
16
16
  "editorJson",
17
17
  "floatingMenu",
18
+ "stateButton",
18
19
  "hiddenPromptsButton",
19
20
  "hiddenImageButton",
20
21
  "hiddenLinkButton",
@@ -34,43 +35,50 @@ export class Controller extends BaseController {
34
35
  heading: {
35
36
  levels: [2, 3, 4],
36
37
  },
37
- }),
38
- // Placeholder.configure({
39
- // emptyEditorClass: this.emptyClass,
40
- // placeholder: this.placeholderValue,
41
- // }),
42
- Link.configure({
43
- openOnClick: false,
44
- // Internal links are stored as GlobalIDs (resolved to URLs at render
45
- // time); without this the gid:// scheme fails Link's URI validation
46
- // and setLink silently does nothing.
47
- protocols: ["gid"],
48
- HTMLAttributes: {
49
- "data-action": "trek--form--content-editor#selectLink",
50
- target: null,
38
+ link: {
39
+ openOnClick: false,
40
+ // Internal links are stored as GlobalIDs (resolved to URLs at
41
+ // render time): without this, setLink refuses the gid:// scheme
42
+ // and silently does nothing. A check rather than the `protocols`
43
+ // option, which registers the scheme with linkifyjs, and warns
44
+ // from the second editor on ("linkifyjs: already initialized").
45
+ isAllowedUri: (url, { defaultValidate }) =>
46
+ url.startsWith("gid://") || defaultValidate(url),
47
+ HTMLAttributes: {
48
+ "data-action": `${this.identifier}#selectLink`,
49
+ target: null,
50
+ },
51
51
  },
52
+ // Not in Trek's editor before Tiptap 3: no underline (nor its
53
+ // Ctrl+U shortcut), and no empty paragraph added after a final
54
+ // block, which would change every saved document
55
+ underline: false,
56
+ trailingNode: false,
52
57
  }),
53
58
  TableOfContents,
54
59
  this.hasFloatingMenuTarget
55
- ? FloatingMenu.configure({
56
- element: this.floatingMenuTarget,
57
- })
60
+ ? FloatingMenu.configure({ element: this.floatingMenuTarget })
58
61
  : null,
59
62
  ImageBlock,
60
63
  PromptsBlock,
61
- ],
64
+ ].filter(Boolean),
62
65
  content: this.contentJson,
63
66
  autofocus: this.autofocusValue ? "end" : false,
67
+ onUpdate: () => this.updateContent(),
68
+ // Every change of the document or the selection: the buttons show the
69
+ // styles and the block where the cursor is
70
+ onTransaction: () => this.updateButtons(),
64
71
  });
65
72
 
66
- this.editor.on("update", () => this.updateContent());
67
- this.editor.on("selectionUpdate", (e) => this.updateSelection(e));
73
+ // Tiptap 3 moves the floating menu next to the editor when it shows it,
74
+ // and out of the page when it hides it, but leaves it where the template
75
+ // puts it until then: out of the page from the start, as Tiptap 2 did
76
+ if (this.hasFloatingMenuTarget) this.floatingMenuTarget.remove();
68
77
  this.updateContent();
78
+ this.updateButtons();
69
79
  }
70
80
 
71
81
  disconnect() {
72
- this.editor.off("update", () => this.updateContent());
73
- this.editor.off("selectionUpdate", () => this.updateSelection());
74
82
  this.editor.destroy();
75
83
  this.editor = null;
76
84
  }
@@ -79,264 +87,99 @@ export class Controller extends BaseController {
79
87
  this.editor.chain().focus().run();
80
88
  }
81
89
 
90
+ // A toolbar button's command, from its params (see the component's `command_data`)
91
+ run({ params: { command, level } }) {
92
+ this.editor
93
+ .chain()
94
+ .focus()
95
+ [command](level ? { level } : undefined)
96
+ .run();
97
+ }
98
+
82
99
  updateContent() {
83
100
  this.editorJsonTarget.value = JSON.stringify(this.editor.getJSON());
84
101
  }
85
102
 
86
- updateSelection() {
87
- const buttons = [
88
- { key: "bold" },
89
- { key: "italic" },
90
- { key: "strike" },
91
- { key: "code" },
92
- { key: "link" },
93
- { key: "paragraph" },
94
- { key: "h1", command: "heading", params: { level: 1 } },
95
- { key: "h2", command: "heading", params: { level: 2 } },
96
- { key: "h3", command: "heading", params: { level: 3 } },
97
- { key: "h4", command: "heading", params: { level: 4 } },
98
- { key: "h5", command: "heading", params: { level: 5 } },
99
- { key: "h6", command: "heading", params: { level: 6 } },
100
- { key: "bulletList" },
101
- { key: "orderedList" },
102
- { key: "codeBlock" },
103
- { key: "blockquote" },
104
- ];
105
-
106
- buttons.forEach((b) => {
107
- const button = this.element.querySelector("button." + b.key);
108
-
109
- if (!button) return;
110
-
111
- const active = this.editor.isActive(b.command || b.key, b.params || {});
112
-
103
+ updateButtons() {
104
+ this.stateButtonTargets.forEach((button) => {
105
+ const level = Number(
106
+ button.getAttribute(`data-${this.identifier}-level-param`)
107
+ );
108
+ const active = this.editor.isActive(
109
+ button.dataset.stateName,
110
+ level ? { level } : {}
111
+ );
113
112
  button.classList.toggle(this.activeClass, active);
114
113
  });
115
114
  }
116
115
 
117
- toggleBold(e) {
118
- this.editor.chain().focus().toggleBold().run();
119
- e.target.classList.toggle(this.activeClass, this.editor.isActive("bold"));
120
- }
121
-
122
- toggleItalic(e) {
123
- this.editor.chain().focus().toggleItalic().run();
124
- e.target.classList.toggle(this.activeClass, this.editor.isActive("italic"));
125
- }
126
-
127
- toggleStrike(e) {
128
- this.editor.chain().focus().toggleStrike().run();
129
- e.target.classList.toggle(this.activeClass, this.editor.isActive("strike"));
130
- }
131
-
132
- toggleCode(e) {
133
- this.editor.chain().focus().toggleCode().run();
134
- e.target.classList.toggle(this.activeClass, this.editor.isActive("code"));
135
- }
116
+ // Links
136
117
 
137
118
  selectLink() {
138
119
  this.editor.commands.extendMarkRange("link");
139
120
  }
140
121
 
141
- toggleLink(e) {
122
+ toggleLink() {
142
123
  this.selectLink();
143
- e.target.classList.toggle(this.activeClass, this.editor.isActive("link"));
144
-
145
- const attributes = this.editor.getAttributes("link");
146
- attributes.text = this.currentlySelectedText();
147
-
148
- const url = this.hiddenLinkButtonTarget.dataset.basePath;
149
- this.hiddenLinkButtonTarget.href = url + "&" + this.linkParams(attributes);
150
- this.hiddenLinkButtonTarget.click();
151
- }
152
-
153
- linkParams(attributes) {
154
- const linkAttributes = Object.fromEntries(
155
- Object.entries(attributes).map(([k, v]) => [`link[${k}]`, v])
156
- );
157
- return new URLSearchParams(linkAttributes).toString();
158
- }
159
-
160
- currentlySelectedText() {
161
- const state = this.editor.state;
162
- const text = state.doc.textBetween(
163
- state.selection.from,
164
- state.selection.to
165
- );
166
-
167
- return text;
168
- }
169
-
170
- linkInserterTargetConnected(t) {
171
- const params = JSON.parse(t.dataset.params);
172
-
173
- this.editor
174
- .chain()
175
- .focus()
176
- .setLink({
177
- href: params.href,
178
- target: params.target,
179
- title: params.title,
180
- })
181
- .run();
182
-
183
- t.parentNode.removeChild(t);
184
- }
185
-
186
- unsetAllMarks() {
187
- this.editor.chain().focus().unsetAllMarks().run();
188
- }
189
-
190
- clearNodes() {
191
- this.editor.chain().focus().clearNodes().run();
192
- }
193
-
194
- setParagraph(e) {
195
- this.editor.chain().focus().setParagraph().run();
196
- e.target.classList.toggle(
197
- this.activeClass,
198
- this.editor.isActive("paragraph")
199
- );
200
- }
201
-
202
- toggleHeading(e) {
203
- const level = parseInt(e.target.dataset.level, 10);
204
- this.editor.chain().focus().toggleHeading({ level: level }).run();
205
- e.target.classList.toggle(
206
- this.activeClass,
207
- this.editor.isActive("heading", { level: level })
208
- );
209
- }
210
-
211
- toggleBulletList(e) {
212
- this.editor.chain().focus().toggleBulletList().run();
213
- e.target.classList.toggle(
214
- this.activeClass,
215
- this.editor.isActive("bulletList")
216
- );
217
- }
218
-
219
- toggleOrderedList(e) {
220
- this.editor.chain().focus().toggleOrderedList().run();
221
- e.target.classList.toggle(
222
- this.activeClass,
223
- this.editor.isActive("orderedList")
224
- );
225
- }
226
124
 
227
- toggleCodeBlock(e) {
228
- this.editor.chain().focus().toggleCodeBlock().run();
229
- e.target.classList.toggle(
230
- this.activeClass,
231
- this.editor.isActive("codeBlock")
232
- );
125
+ const attributes = {
126
+ ...this.editor.getAttributes("link"),
127
+ text: this.selectedText,
128
+ };
129
+ this.openPanel(this.hiddenLinkButtonTarget, toParams("link", attributes));
233
130
  }
234
131
 
235
- toggleBlockquote(e) {
236
- this.editor.chain().focus().toggleBlockquote().run();
237
- e.target.classList.toggle(
238
- this.activeClass,
239
- this.editor.isActive("blockquote")
240
- );
132
+ get selectedText() {
133
+ const { doc, selection } = this.editor.state;
134
+ return doc.textBetween(selection.from, selection.to);
241
135
  }
242
136
 
243
- setHorizontalRule() {
244
- this.editor.chain().focus().setHorizontalRule().run();
137
+ linkInserterTargetConnected(inserter) {
138
+ const { href, target, title } = JSON.parse(inserter.dataset.params);
139
+ this.editor.chain().focus().setLink({ href, target, title }).run();
140
+ inserter.remove();
245
141
  }
246
142
 
247
- setHardBreak() {
248
- this.editor.chain().focus().setHardBreak().run();
249
- }
143
+ // Blocks (calls to action, images): a block's button carries its
144
+ // attributes (see block_view.js), the floating menu's none
250
145
 
251
- undo() {
252
- this.editor.chain().focus().undo().run();
146
+ togglePrompts(event) {
147
+ this.openPanel(this.hiddenPromptsButtonTarget, blockParams(event));
253
148
  }
254
149
 
255
- redo() {
256
- this.editor.chain().focus().redo().run();
150
+ toggleImage(event) {
151
+ this.openPanel(this.hiddenImageButtonTarget, blockParams(event));
257
152
  }
258
153
 
259
- togglePrompts(e) {
260
- const params = e.target.closest("button").dataset.params;
261
- const url = this.hiddenPromptsButtonTarget.dataset.basePath;
262
- this.hiddenPromptsButtonTarget.href = url + (params ? "&" + params : "");
263
- this.hiddenPromptsButtonTarget.click();
154
+ // The panel's response: a new block, at the cursor, or an edited one, in
155
+ // place of the block selected by clicking it
156
+ contentInserterTargetConnected(inserter) {
157
+ this.editor.commands.insertContent(inserter.innerHTML);
158
+ inserter.remove();
264
159
  }
265
160
 
266
- contentInserterTargetConnected(t) {
267
- const content = t.innerHTML;
268
-
269
- if (this._editingImagePos != null) {
270
- // Update existing image block in place
271
- const pos = this._editingImagePos;
272
- this._editingImagePos = null;
273
-
274
- const parser = new DOMParser();
275
- const doc = parser.parseFromString(content, "text/html");
276
- const imageBlock = doc.querySelector("image-block");
277
-
278
- if (imageBlock) {
279
- const attrs = {};
280
- for (const attr of imageBlock.attributes) {
281
- attrs[attr.name] = attr.value;
282
- }
283
- const tr = this.editor.state.tr;
284
- const node = this.editor.state.doc.nodeAt(pos);
285
- if (node && node.type.name === "imageBlock") {
286
- tr.setNodeMarkup(pos, undefined, attrs);
287
- this.editor.view.dispatch(tr);
288
- } else {
289
- this.editor.commands.insertContent(content);
290
- }
291
- } else {
292
- this.editor.commands.insertContent(content);
293
- }
294
- } else {
295
- this.editor.commands.insertContent(content);
296
- }
297
-
298
- t.parentNode.removeChild(t);
299
- }
300
-
301
- toggleImage(e) {
302
- const button = e.target.closest("button");
303
- const params = button.dataset.params;
304
-
305
- // Check if clicking an existing image block in the editor
306
- const imageBlockEl = button.closest("image-block");
307
- if (imageBlockEl) {
308
- const pos = this.editor.view.posAtDOM(imageBlockEl, 0);
309
- const resolved = this.editor.state.doc.resolve(pos);
310
- // Walk up to find the imageBlock node position
311
- for (let d = resolved.depth; d >= 0; d--) {
312
- if (resolved.node(d).type.name === "imageBlock") {
313
- this._editingImagePos = d > 0 ? resolved.before(d) : pos;
314
- break;
315
- }
316
- }
317
- // Fallback: the pos itself might point at the imageBlock
318
- if (this._editingImagePos == null) {
319
- const node = this.editor.state.doc.nodeAt(pos);
320
- if (node && node.type.name === "imageBlock") {
321
- this._editingImagePos = pos;
322
- }
323
- }
324
- } else {
325
- this._editingImagePos = null;
326
- }
327
-
328
- const url = this.hiddenImageButtonTarget.dataset.basePath;
329
- this.hiddenImageButtonTarget.href = url + (params ? "&" + params : "");
330
- this.hiddenImageButtonTarget.click();
161
+ // Opens a panel through its hidden link, with the given params
162
+ openPanel(link, params) {
163
+ const url = link.dataset.basePath;
164
+ link.href = params ? `${url}&${params}` : url;
165
+ link.click();
331
166
  }
332
167
 
333
168
  get contentJson() {
334
169
  const { value } = this.editorJsonTarget;
335
-
336
- if (value == "") {
337
- return "";
338
- } else {
339
- return JSON.parse(value);
340
- }
170
+ return value == "" ? "" : JSON.parse(value);
341
171
  }
342
172
  }
173
+
174
+ function toParams(scope, attributes) {
175
+ return new URLSearchParams(
176
+ Object.entries(attributes).map(([name, value]) => [
177
+ `${scope}[${name}]`,
178
+ value ?? "",
179
+ ])
180
+ ).toString();
181
+ }
182
+
183
+ function blockParams(event) {
184
+ return event.target.closest("button")?.dataset.params;
185
+ }
@@ -0,0 +1,40 @@
1
+ // The editor's view of a block edited in a panel (image, prompt): a button opening the panel with the
2
+ // block's attributes. Clicking it selects the block, so that the panel's response replaces it. Built with
3
+ // DOM nodes rather than HTML, so that the attributes, typed by authors, stay text.
4
+ export function blockView({
5
+ tag,
6
+ param,
7
+ actions,
8
+ attributes,
9
+ editor,
10
+ getPos,
11
+ render,
12
+ }) {
13
+ const dom = document.createElement(tag);
14
+ const button = document.createElement("button");
15
+ button.type = "button";
16
+ button.dataset.action = actions;
17
+ button.dataset.params = new URLSearchParams(
18
+ Object.entries(attributes).map(([name, value]) => [
19
+ `${param}[${name}]`,
20
+ value ?? "",
21
+ ])
22
+ ).toString();
23
+ // Before the Stimulus actions, which run on the same click and open the panel
24
+ button.addEventListener("click", () => {
25
+ const pos = getPos();
26
+ if (pos !== undefined) editor.commands.setNodeSelection(pos);
27
+ });
28
+ render(button);
29
+ dom.append(button);
30
+ return { dom };
31
+ }
32
+
33
+ export function element(tag, properties = {}, ...children) {
34
+ const node = document.createElement(tag);
35
+ Object.entries(properties).forEach(([name, value]) => {
36
+ if (value !== null && value !== undefined) node.setAttribute(name, value);
37
+ });
38
+ node.append(...children);
39
+ return node;
40
+ }
@@ -1,11 +1,5 @@
1
1
  import { Node, mergeAttributes } from "@tiptap/core";
2
-
3
- function attributesToParams(attributes) {
4
- const params = Object.fromEntries(
5
- Object.entries(attributes).map(([k, v]) => [`image[${k}]`, v])
6
- );
7
- return new URLSearchParams(params).toString();
8
- }
2
+ import { blockView, element } from "./block_view.js";
9
3
 
10
4
  export default Node.create({
11
5
  name: "imageBlock",
@@ -48,24 +42,32 @@ export default Node.create({
48
42
  },
49
43
 
50
44
  addNodeView() {
51
- return ({ node }) => {
52
- const dom = document.createElement("image-block");
53
- const params = attributesToParams(node.attrs);
54
-
55
- dom.innerHTML = `
56
- <button type="button" data-action="trek--form--content-editor#toggleImage trek--layout#openPanel" data-params="${params}">
57
- <span class="c-trek--form--content-editor-content-image" data-size="${
58
- node.attrs.size
59
- }">
60
- <img src="${node.attrs.file_url}" alt="${node.attrs.alt}" />
61
- </span>
62
- ${node.attrs.text ? `<span>${node.attrs.text}</span>` : ""}
63
- </button>
64
- `;
65
-
66
- return {
67
- dom,
68
- };
69
- };
45
+ return ({ node, editor, getPos }) =>
46
+ blockView({
47
+ tag: "image-block",
48
+ param: "image",
49
+ actions:
50
+ "trek--form--content-editor#toggleImage trek--layout#openPanel",
51
+ attributes: node.attrs,
52
+ editor,
53
+ getPos,
54
+ render: (button) => {
55
+ button.append(
56
+ element(
57
+ "span",
58
+ {
59
+ class: "c-trek--form--content-editor-content-image",
60
+ "data-size": node.attrs.size,
61
+ },
62
+ element("img", {
63
+ src: node.attrs.file_url,
64
+ alt: node.attrs.alt ?? "",
65
+ })
66
+ )
67
+ );
68
+ if (node.attrs.text)
69
+ button.append(element("span", {}, node.attrs.text));
70
+ },
71
+ });
70
72
  },
71
73
  });
@@ -1,11 +1,5 @@
1
1
  import { Node, mergeAttributes } from "@tiptap/core";
2
-
3
- function attributesToParams(attributes) {
4
- const params = Object.fromEntries(
5
- Object.entries(attributes).map(([k, v]) => [`prompt[${k}]`, v])
6
- );
7
- return new URLSearchParams(params).toString();
8
- }
2
+ import { blockView } from "./block_view.js";
9
3
 
10
4
  export default Node.create({
11
5
  name: "promptsBlock",
@@ -14,9 +8,6 @@ export default Node.create({
14
8
 
15
9
  addAttributes() {
16
10
  return {
17
- color: {
18
- default: null,
19
- },
20
11
  href: {
21
12
  default: null,
22
13
  },
@@ -45,21 +36,19 @@ export default Node.create({
45
36
  },
46
37
 
47
38
  addNodeView() {
48
- return ({ node }) => {
49
- const dom = document.createElement("prompts-block");
50
- const params = attributesToParams(node.attrs);
51
-
52
- dom.innerHTML = `
53
- <button type="button" class="color-${node.attrs.color}" title="${
54
- node.attrs.title || ""
55
- }" data-action="trek--form--content-editor#togglePrompts trek--layout#openPanel" data-params="${params}">${
56
- node.attrs.text
57
- }</button>
58
- `;
59
-
60
- return {
61
- dom,
62
- };
63
- };
39
+ return ({ node, editor, getPos }) =>
40
+ blockView({
41
+ tag: "prompts-block",
42
+ param: "prompt",
43
+ actions:
44
+ "trek--form--content-editor#togglePrompts trek--layout#openPanel",
45
+ attributes: node.attrs,
46
+ editor,
47
+ getPos,
48
+ render: (button) => {
49
+ button.title = node.attrs.title || "";
50
+ button.textContent = node.attrs.text;
51
+ },
52
+ });
64
53
  },
65
54
  });
@@ -1,5 +1,5 @@
1
1
  import { Extension } from "@tiptap/core";
2
- import { Plugin, PluginKey } from "prosemirror-state";
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
3
3
  import slugify from "slugify";
4
4
 
5
5
  const isTargetNodeOfType = (node, type) => node.type === type;
@@ -19,13 +19,11 @@ export const TableOfContents = Extension.create({
19
19
  },
20
20
  },
21
21
  renderHTML: (attributes) => {
22
- console.log("renderHTML", attributes);
23
22
  return {
24
23
  id: attributes.id,
25
24
  };
26
25
  },
27
26
  parseHTML: (element) => {
28
- console.log("parseHTML", element);
29
27
  return element.id;
30
28
  },
31
29
  },
@@ -35,6 +35,21 @@ module Trek
35
35
  )
36
36
  end
37
37
 
38
+ # A toolbar button running a Tiptap command (`editor.chain().focus()[command]({ level })`), marked
39
+ # active when the selection has `active`, e.g. "bold", or "heading" with the button's level
40
+ def command_data(command, active: nil, level: nil)
41
+ {
42
+ action: stimulus_action("run"),
43
+ "#{identifier}-command-param": command,
44
+ "#{identifier}-level-param": level
45
+ }.compact.merge(active ? state_data(active) : {})
46
+ end
47
+
48
+ # A button marked active when the selection has `name` (Tiptap's `isActive`)
49
+ def state_data(name)
50
+ { stimulus_target_key => "stateButton", "state-name": name }
51
+ end
52
+
38
53
  def content_data_attributes
39
54
  {
40
55
  action: stimulus_action("click", "focus")
@@ -20,7 +20,7 @@
20
20
  width: 25rem;
21
21
  }
22
22
 
23
- .preview a,
23
+ .original,
24
24
  .preview img {
25
25
  display: block;
26
26
  height: 100%;
@@ -31,6 +31,45 @@
31
31
  object-fit: contain;
32
32
  }
33
33
 
34
+ /* The links to the original, opening and downloading it: icons over a darkened image, on hover and focus */
35
+ .original {
36
+ position: relative;
37
+ }
38
+
39
+ .original-actions {
40
+ --icon-size: 2rem;
41
+
42
+ align-items: center;
43
+ background-color: rgb(0 0 0 / 45%);
44
+ display: flex;
45
+ gap: 0.8rem;
46
+ inset: 0;
47
+ justify-content: center;
48
+ opacity: 0;
49
+ position: absolute;
50
+ transition: opacity 0.15s ease-in-out;
51
+ }
52
+
53
+ .original:hover .original-actions,
54
+ .original:focus-within .original-actions {
55
+ opacity: 1;
56
+ }
57
+
58
+ .original-action {
59
+ align-items: center;
60
+ border-radius: 0.4rem;
61
+ color: white;
62
+ display: flex;
63
+ justify-content: center;
64
+ padding: 0.8rem;
65
+ transition: background-color 0.15s ease-in-out;
66
+ }
67
+
68
+ .original-action:hover,
69
+ .original-action:focus-visible {
70
+ background-color: rgb(255 255 255 / 20%);
71
+ }
72
+
34
73
  .actions {
35
74
  align-items: flex-start;
36
75
  display: flex;