Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0a8ef01
Revert "Revert "feat(tiptap): extend toolbar slot button icon mapping…
ornsteinfilip Nov 29, 2025
3ad1fc3
build(tiptap): compile extended icon mapping
ornsteinfilip Nov 29, 2025
2b999f5
feat(tiptap): add extensible toolbar with custom icons and dropdown g…
ornsteinfilip Nov 29, 2025
bd61975
feat(tiptap): add toolbar_groups configuration via Rails config
ornsteinfilip Nov 29, 2025
bba1d0e
fix(tiptap): add NODE_ICONS map to dropdown groups for individual nod…
ornsteinfilip Nov 29, 2025
d05534e
feat(tiptap): add icons and grouping to slash command popup
ornsteinfilip Nov 29, 2025
60d7846
feat(tiptap): enhance toolbar with custom icons and dropdown groups
ornsteinfilip Nov 29, 2025
a5e414b
feat(batch_service): enhance Redis client configuration with reconnec…
ornsteinfilip Dec 1, 2025
ee69a74
chore(guard): run rubocop
mreq Dec 1, 2025
988cf66
guard(forms): ensure consistent rendering of form partials in edit an…
ornsteinfilip Dec 1, 2025
1dd35f4
fix(embed): instagram embed from url width (#485)
VladaTrefil Dec 1, 2025
fc964fe
fix(has_attachments): respect in-memory placements during validation …
mreq Dec 1, 2025
3b00aaf
Revert "guard(forms): ensure consistent rendering of form partials in…
mreq Dec 1, 2025
012866e
fix(embed): remove manual Twitter tweet rendering to prevent double r…
mreq Dec 2, 2025
4bcdcd7
feat(files): improve MOV videos support (#487)
mreq Dec 2, 2025
1d93ce3
docs: add AGENTS.md with Rails best practices and View Components gui…
mreq Dec 2, 2025
c6f24cb
docs: update AGENTS.md template
mreq Dec 2, 2025
67a3516
docs(agents): update commit examples with real project commits
mreq Dec 2, 2025
e2edcca
feat(tiptap): update empty paragraphs handling, placeholders and fix …
mreq Dec 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .cursorrules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Cursor Rules

Read and apply instructions from AGENTS.md at the project root.
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
# something like RVM for anything, not just ruby
ruby 3.3.3
# nodejs 14.15.1
nodejs 20
71 changes: 71 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Agent Instructions

## AGENTS.md File Resolution

AGENTS.md files can be placed at the project root or in subdirectories. When multiple files exist, traverse up from the edited file's directory to the root, collecting all AGENTS.md files. Files closer to the edited file take precedence over files further away.

**Example:** Files in the `tiptap/` directory use `tiptap/AGENTS.md` which overrides the JavaScript formatting/linting instructions from the root `AGENTS.md` (using eslint/prettier instead of standardjs).

## Code Formatting and Linting

After editing any code files, automatically format and lint them using the appropriate tools for that language.

### Bash/Shell
- Format: `shfmt -w <file_path>`
- Lint: `shellcheck <file_path>`

### Ruby/Ruby on Rails
- Format & Lint: `bundle exec rubocop --autocorrect-all <file_path>`
- Note: Guard automatically runs rubocop on Ruby files when they change. For manual runs, use the command above.

### JavaScript
- Format & Lint: `npx standard --fix <file_path>`
- Note: Guard automatically runs standardjs on JavaScript files when they change. For manual runs, use the command above.

### Slim
- Lint: `slim-lint <file_path>`
- Note: Guard automatically runs slimlint on Slim files when they change. For manual runs, use the command above.

## Rails Best Practices

- Use TDD (write tests first)
- Use early returns to reduce nesting
- Keep methods focused and under ~20 lines
- Use namespaces for entire functionality (all related models, controllers, components together)

## View Components

- Always use the component generator: `rails generate folio:component blog/post` generates `MyApp::Blog::PostComponent`
- Use BEM methodology for CSS class names
- Block (B) is generated from component class name - takes first letter (lowercase) of top-level namespace + rest of namespace path in kebab-case
- Example: `MyApp::Blog::PostComponent` → Block is `"m-blog-post"`
- Special case: `Folio::Console::` → `f-c`
- Elements (E) and Modifiers (M) follow standard BEM: `__element` and `--modifier`
- Example: `m-blog-post__button` (element), `m-blog-post__button--active` (modifier)
- Stimulus: Use `stimulus_controller("controller-name", values: {...}, action: {...}, classes: [...])` helper for JavaScript behavior
- See [docs/components.md](docs/components.md) for detailed component guidelines

## File Formatting Standards

When editing any file:
- Remove trailing whitespace from all lines
- Keep a single newline at the end of file (EOF)

## Git Commits

All commits must use semantic commit messages:

```
<type>(<scope>): <subject>

<body>
```

**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`

**Examples:**
- `feat(nested_fields): add sortable auto scroll`
- `chore(react): standardjs lint`
- `docs(tiptap): add early returns preference to AGENTS.md`

Scope is optional but recommended for clarity. Describe the final state/outcome, not the implementation steps. Keep the message concise and focused on what was achieved.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ All notable changes to this project will be documented in this file.
- added `disabled_modifications` option to `form_footer`
- added `calendar_filter` icon
- added `thumbnail_configuration` to `Folio::File` to store crop data per-ratio, set via a new `Folio::Console::Files::Show::Thumbnails::CropEditComponent` and `update_thumbnails_crop` API
- support for MOV (video/quicktime) files

### Changed

Expand All @@ -42,6 +43,7 @@ All notable changes to this project will be documented in this file.
### Fixed

- file_list/file_component info-file-name doesn't break on long names
- instagram embeds from url loading with small width

## [6.5.1] - 2025-06-18

Expand Down
48 changes: 47 additions & 1 deletion app/assets/stylesheets/folio/tiptap/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ $f-tiptap__spacer: 1rem !default;
$f-tiptap__media-min-width--tablet: 576px !default;
$f-tiptap__media-min-width--desktop: 708px !default;

$f-tiptap-root__background-color: transparent !default;
$f-tiptap-root__background-color: var(--bs-body-bg, #ffffff) !default;
$f-tiptap-root__color: inherit !default;
$f-tiptap-root__font-family: inherit !default;
$f-tiptap-root__font-size: inherit !default;
Expand Down Expand Up @@ -487,6 +487,10 @@ $f-tiptap-table-td__min-width: 5rem !default;
}

.f-tiptap-float {
// Default values for CSS custom properties (below 576px, float layout is not active)
--f-tiptap-float__aside-width: 0;
--f-tiptap-float__aside-margin-x: 0;
--f-tiptap-float__aside-offset: 0;
position: relative;
}

Expand Down Expand Up @@ -517,6 +521,25 @@ $f-tiptap-table-td__min-width: 5rem !default;
background-color: $f-tiptap-float__aside-background-color-in-editor-only;
}

// Force two-line placeholder for empty paragraphs that are only child of [data-node-view-content-react]
// Get numeric line-height value for multiline placeholder - use variable if numeric, otherwise default to 1.5
$multiline-placeholder-line-height: if(
type-of($f-tiptap-root__line-height) == "number",
$f-tiptap-root__line-height,
1.5
);

.f-tiptap-float__aside,
.f-tiptap-float__main {
[data-node-view-content-react] > p.is-empty:only-child,
[data-node-view-content-react] > p:has(.ProseMirror-trailingBreak):only-child {
// Use 2lh (line-height unit) - represents 2 line-heights of the element
// Fallback for browsers that don't support lh unit, using calculated numeric value
min-height: calc(2 * 1em * #{$multiline-placeholder-line-height});
min-height: 2lh; // Modern browsers: 2 line-heights
}
}

.f-tiptap-styled-paragraph {
@each $variant, $map in $f-tiptap__styled-paragraph-variants {
&[data-f-tiptap-styled-paragraph-variant="#{$variant}"] {
Expand Down Expand Up @@ -644,13 +667,31 @@ $f-tiptap-table-td__min-width: 5rem !default;
--f-tiptap-hr__margin: #{$f-tiptap-hr__margin--tablet};

.f-tiptap-float {
// Default aside width (medium)
--f-tiptap-float__aside-width: #{$f-tiptap-float__aside-width-medium};
--f-tiptap-float__aside-side: left;
--f-tiptap-float__aside-margin-x: #{$f-tiptap-float__aside-margin-x--tablet};
--f-tiptap-float__aside-offset: #{$f-tiptap-float__aside-offset--tablet};

&::after {
content: "";
clear: both;
display: table;
}
}

.f-tiptap-float[data-f-tiptap-float-size="small"] {
--f-tiptap-float__aside-width: #{$f-tiptap-float__aside-width-small};
}

.f-tiptap-float[data-f-tiptap-float-size="large"] {
--f-tiptap-float__aside-width: #{$f-tiptap-float__aside-width-large};
}

.f-tiptap-float[data-f-tiptap-float-side="right"] {
--f-tiptap-float__aside-side: right;
}

.f-tiptap-float__aside {
float: left;
margin-right: $f-tiptap-float__aside-margin-x--tablet;
Expand Down Expand Up @@ -727,6 +768,11 @@ $f-tiptap-table-td__min-width: 5rem !default;
gap: $f-tiptap-columns__gap--desktop;
}

.f-tiptap-float {
--f-tiptap-float__aside-margin-x: #{$f-tiptap-float__aside-margin-x--desktop};
--f-tiptap-float__aside-offset: #{$f-tiptap-float__aside-offset--desktop};
}

.f-tiptap-float__aside {
margin-right: $f-tiptap-float__aside-margin-x--desktop;
margin-bottom: $f-tiptap-float__aside-margin-y--desktop;
Expand Down
7 changes: 6 additions & 1 deletion app/components/folio/player_component.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,12 @@ window.Folio.Player.innerBind = (el, opts, file) => {
child.muted = false

const source = document.createElement('source')
source.type = fileAttributes.player_source_mime_type
// Map video/quicktime to video/mp4 for browser compatibility
// MOV files often contain H.264 codec which browsers can play as MP4
const mimeType = fileAttributes.player_source_mime_type === 'video/quicktime'
? 'video/mp4'
: fileAttributes.player_source_mime_type
source.type = mimeType
source.src = fileAttributes.mux_source_url || fileAttributes.source_url

child.appendChild(source)
Expand Down
11 changes: 9 additions & 2 deletions app/components/folio/uppy_component.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ window.Folio.Stimulus.register('f-uppy', class extends window.Stimulus.Controlle
}

if (this.allowedFormatsValue) {
restrictions.allowedFileTypes = this.allowedFormatsValue.split(',')
const allowedTypes = this.allowedFormatsValue.split(',').map(type => type.trim())

// Add .mov extension if video/quicktime is allowed
if (allowedTypes.includes('video/quicktime') && !allowedTypes.includes('.mov')) {
allowedTypes.push('.mov')
}

restrictions.allowedFileTypes = allowedTypes
}

if (this.maxFileSizeValue) {
Expand All @@ -94,7 +101,7 @@ window.Folio.Stimulus.register('f-uppy', class extends window.Stimulus.Controlle

if (this.allowedFormatsValue) {
const formattedFormats = this.allowedFormatsValue.split(',')
.map(format => format.trim().toUpperCase().split('/', 2).pop().replace('SVG+XML', 'SVG'))
.map(format => format.trim().toUpperCase().split('/', 2).pop().replace('SVG+XML', 'SVG').replace('QUICKTIME', 'MOV'))
.join(', ')

const supportedFormatsLabel = window.Folio.i18n(this.constructor.ERROR_MESSAGES, 'supportedFormats')
Expand Down
11 changes: 8 additions & 3 deletions app/lib/folio/tiptap/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ class Config
:pages_component_class_name,
:heading_levels,
:autosave,
:embed_node_class_name
:embed_node_class_name,
:toolbar_groups

def initialize(node_names: nil,
styled_paragraph_variants: nil,
Expand All @@ -19,14 +20,16 @@ def initialize(node_names: nil,
pages_component_class_name: nil,
heading_levels: nil,
autosave: true,
embed_node_class_name: nil)
embed_node_class_name: nil,
toolbar_groups: nil)
@node_names = node_names || get_all_tiptap_node_names
@styled_paragraph_variants = styled_paragraph_variants || default_styled_paragraph_variants
@styled_wrap_variants = styled_wrap_variants || default_styled_wrap_variants
@pages_component_class_name = pages_component_class_name
@heading_levels = heading_levels || default_heading_levels
@autosave = autosave
@embed_node_class_name = embed_node_class_name
@toolbar_groups = toolbar_groups || []

@schema = schema || build_default_schema
end
Expand All @@ -39,7 +42,8 @@ def to_h
heading_levels: @heading_levels,
pages_component_class_name: @pages_component_class_name,
autosave: @autosave,
embed_node_class_name: @embed_node_class_name
embed_node_class_name: @embed_node_class_name,
toolbar_groups: @toolbar_groups
}
end

Expand All @@ -48,6 +52,7 @@ def to_input_json

h[:nodes] = tiptap_nodes_hash(@node_names)
h[:enable_pages] = @pages_component_class_name.present?
h[:toolbar_groups] = @toolbar_groups if @toolbar_groups.present?

h.to_json
end
Expand Down
13 changes: 10 additions & 3 deletions app/lib/folio/tiptap/node_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ def convert_structure_to_hashes(structure)

# Whitelist of allowed keys and their value types in tiptap_config hash, hashes cannot include keys not listed here.
TIPTAP_CONFIG_HASH_WHITELIST = {
toolbar: { icon: String, slot: String },
toolbar: { icon: String, slot: String, dropdown_group: String },
autoclick_cover: [TrueClass, FalseClass],
}

Expand All @@ -531,8 +531,15 @@ def get_tiptap_config(tiptap_config_hash_or_nil)
end

if TIPTAP_CONFIG_HASH_WHITELIST[key].is_a?(Hash)
unless TIPTAP_CONFIG_HASH_WHITELIST[key].all? { |k, klass| value[k].is_a?(klass) }
raise ArgumentError, "Expected value for `#{key}` in tiptap_config to be a Hash with keys #{TIPTAP_CONFIG_HASH_WHITELIST[key].map { |k, v| "#{k}: #{v}" }.join(', ')}, got #{value.inspect}"
# Validate each provided key, but allow optional keys to be missing
value.each do |k, v|
expected_klass = TIPTAP_CONFIG_HASH_WHITELIST[key][k]
if expected_klass.nil?
raise ArgumentError, "Unknown key `#{k}` in tiptap_config[:#{key}]. Allowed keys are: #{TIPTAP_CONFIG_HASH_WHITELIST[key].keys.join(', ')}"
end
unless v.is_a?(expected_klass)
raise ArgumentError, "Expected value for `#{key}[:#{k}]` in tiptap_config to be of type #{expected_klass}, got #{v.class.name}"
end
end
else
unless TIPTAP_CONFIG_HASH_WHITELIST[key].any? { |klass| value.is_a?(klass) }
Expand Down
41 changes: 13 additions & 28 deletions app/models/concerns/folio/has_attachments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,11 @@ def validate_file_placements_if_needed
end
end

# Filter out placements marked for destruction - they will be removed anyway
placements_to_validate = all_placements_ary.reject(&:marked_for_destruction?)

if should_validate_file_placements_attribution_if_needed?
all_placements_ary.each do |placement|
placements_to_validate.each do |placement|
placement.validate_attribution_if_needed
if placement.errors[:file].present?
has_invalid_file_placements = true
Expand All @@ -341,7 +344,7 @@ def validate_file_placements_if_needed
end

if should_validate_file_placements_alt_if_needed?
all_placements_ary.each do |placement|
placements_to_validate.each do |placement|
placement.validate_alt_if_needed
if placement.errors[:file].present?
has_invalid_file_placements = true
Expand All @@ -350,7 +353,7 @@ def validate_file_placements_if_needed
end

if should_validate_file_placements_description_if_needed?
all_placements_ary.each do |placement|
placements_to_validate.each do |placement|
placement.validate_description_if_needed
if placement.errors[:file].present?
has_invalid_file_placements = true
Expand Down Expand Up @@ -408,30 +411,12 @@ def validate_files_usage_limits_if_publishing
end

def get_files_with_usage_constraints
# Get unique file types that have HasUsageConstraints concern
file_types_with_constraints = Folio::FilePlacement::Base
.joins("INNER JOIN folio_files ON folio_files.id = folio_file_placements.file_id")
.where(placement_id: id, placement_type: self.class.base_class.name)
.distinct
.pluck("folio_files.type")
.compact
.select { |type| type.constantize.included_modules.include?(Folio::File::HasUsageConstraints) }
.uniq

return [] if file_types_with_constraints.empty?

file_ids = Folio::File
.joins(:file_placements)
.where(
type: file_types_with_constraints,
file_placements: {
placement_id: id,
placement_type: self.class.base_class.name
}
)
.distinct
.pluck(:id)

Folio::File.where(id: file_ids)
# Collect placements from in-memory associations to respect unsaved changes from nested attributes
placements = collect_all_placements.reject(&:marked_for_destruction?)

# Get files from placements that have HasUsageConstraints concern
files = placements.filter_map(&:file).compact.uniq

files.select { |file| file.class.included_modules.include?(Folio::File::HasUsageConstraints) }
end
end
2 changes: 1 addition & 1 deletion app/models/folio/file/video.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class Folio::File::Video < Folio::File
include Folio::File::Video::HasSubtitles

validate_file_format %w[video/mp4 video/webm]
validate_file_format %w[video/mp4 video/webm video/quicktime]

def console_show_additional_fields
additional_fields = {}
Expand Down
Loading