diff --git a/app/RSpade/Core/Js/Rsx_Behaviors.js b/app/RSpade/Core/Js/Rsx_Behaviors.js index 34218fa97..a870d1f76 100755 --- a/app/RSpade/Core/Js/Rsx_Behaviors.js +++ b/app/RSpade/Core/Js/Rsx_Behaviors.js @@ -15,7 +15,6 @@ class Rsx_Behaviors { static _on_framework_core_init() { Rsx_Behaviors._init_ignore_invalid_anchor_links(); Rsx_Behaviors._trim_copied_text(); - Rsx_Behaviors._init_file_drop_handler(); } /** @@ -107,163 +106,4 @@ class Rsx_Behaviors { }); } - /** - * Global file drop handler - * - * Intercepts all file drag/drop operations and routes them to components - * marked with the `rsx-droppable` class. Components receive a `file-drop` - * event with the dropped files. - * - * CSS Classes: - * - `rsx-droppable` - Marks element as a valid drop target - * - `rsx-drop-active` - Added to all droppables during file drag - * - `rsx-drop-target` - Added to the specific element that will receive drop - * - * Behavior: - * - Single visible droppable: auto-becomes target - * - Multiple visible droppables: must hover over specific element - * - No-drop cursor when not over valid target - * - Widgets handle their own file type filtering - */ - static _init_file_drop_handler() { - let drag_counter = 0; - let current_target = null; - - // Get all visible droppable elements - const get_visible_droppables = () => { - return $('.rsx-droppable').filter(function() { - const $el = $(this); - // Must be visible and not hidden by CSS - return $el.is(':visible') && $el.css('visibility') !== 'hidden'; - }); - }; - - // Check if event contains files - const has_files = (e) => { - if (e.originalEvent && e.originalEvent.dataTransfer) { - const types = e.originalEvent.dataTransfer.types; - return types && (types.includes('Files') || types.indexOf('Files') >= 0); - } - return false; - }; - - // Update which element is the current target - const update_target = ($new_target) => { - if (current_target) { - $(current_target).removeClass('rsx-drop-target'); - } - current_target = $new_target ? $new_target[0] : null; - if (current_target) { - $(current_target).addClass('rsx-drop-target'); - } - }; - - // Clear all drag state - const clear_drag_state = () => { - drag_counter = 0; - $('.rsx-drop-active').removeClass('rsx-drop-active'); - update_target(null); - }; - - // dragenter - file enters the window - $(document).on('dragenter', function(e) { - if (!has_files(e)) return; - - drag_counter++; - - if (drag_counter === 1) { - // First entry - activate all droppables - const $droppables = get_visible_droppables(); - $droppables.addClass('rsx-drop-active'); - - // If only one droppable, auto-target it - if ($droppables.length === 1) { - update_target($droppables.first()); - } - } - }); - - // dragleave - file leaves an element - $(document).on('dragleave', function(e) { - if (!has_files(e)) return; - - drag_counter--; - - if (drag_counter <= 0) { - clear_drag_state(); - } - }); - - // dragover - file is over an element (fires continuously) - $(document).on('dragover', function(e) { - if (!has_files(e)) return; - - e.preventDefault(); // Required to allow drop - - const $droppables = get_visible_droppables(); - - if ($droppables.length === 0) { - // No drop targets - show no-drop cursor - e.originalEvent.dataTransfer.dropEffect = 'none'; - return; - } - - if ($droppables.length === 1) { - // Single target - already set, allow copy - e.originalEvent.dataTransfer.dropEffect = 'copy'; - return; - } - - // Multiple targets - find if we're over one - const $hovered = $(e.target).closest('.rsx-droppable'); - - if ($hovered.length && $hovered.hasClass('rsx-drop-active')) { - // Over a valid droppable - update_target($hovered); - e.originalEvent.dataTransfer.dropEffect = 'copy'; - } else { - // Not over any droppable - show no-drop cursor - update_target(null); - e.originalEvent.dataTransfer.dropEffect = 'none'; - } - }); - - // drop - file is dropped - $(document).on('drop', function(e) { - if (!has_files(e)) return; - - e.preventDefault(); - e.stopPropagation(); - - const files = e.originalEvent.dataTransfer.files; - - if (current_target && files.length > 0) { - // Trigger file-drop event on the component - const $target = $(current_target); - const component = $target.component(); - - if (component) { - component.trigger('file-drop', { - files: files, - dataTransfer: e.originalEvent.dataTransfer, - originalEvent: e.originalEvent - }); - } else { - // No component - trigger jQuery event on element directly - $target.trigger('file-drop', { - files: files, - dataTransfer: e.originalEvent.dataTransfer, - originalEvent: e.originalEvent - }); - } - } - - clear_drag_state(); - }); - - // Handle drag end (e.g., user presses Escape) - $(document).on('dragend', function(e) { - clear_drag_state(); - }); - } } diff --git a/app/RSpade/Core/Js/Rsx_Droppable.js b/app/RSpade/Core/Js/Rsx_Droppable.js new file mode 100755 index 000000000..60c4a0801 --- /dev/null +++ b/app/RSpade/Core/Js/Rsx_Droppable.js @@ -0,0 +1,167 @@ +/** + * Rsx_Droppable - Global File Drop Handler + * + * Intercepts all file drag/drop operations and routes them to components + * marked with the `rsx-droppable` class. Components receive a `file-drop` + * event with the dropped files. + * + * CSS Classes: + * - `rsx-droppable` - Marks element as a valid drop target + * - `rsx-drop-active` - Added to all droppables during file drag + * - `rsx-drop-target` - Added to the specific element that will receive drop + * + * Behavior: + * - Single visible droppable: auto-becomes target + * - Multiple visible droppables: must hover over specific element + * - No-drop cursor when not over valid target + * - Widgets handle their own file type filtering + * + * @internal Framework use only - not part of public API + */ +class Rsx_Droppable { + static _on_framework_core_init() { + Rsx_Droppable._init(); + } + + static _init() { + let drag_counter = 0; + let current_target = null; + + // Get all visible droppable elements + const get_visible_droppables = () => { + return $('.rsx-droppable').filter(function() { + const $el = $(this); + // Must be visible and not hidden by CSS + return $el.is(':visible') && $el.css('visibility') !== 'hidden'; + }); + }; + + // Check if event contains files + const has_files = (e) => { + if (e.originalEvent && e.originalEvent.dataTransfer) { + const types = e.originalEvent.dataTransfer.types; + return types && (types.includes('Files') || types.indexOf('Files') >= 0); + } + return false; + }; + + // Update which element is the current target + const update_target = ($new_target) => { + if (current_target) { + $(current_target).removeClass('rsx-drop-target'); + } + current_target = $new_target ? $new_target[0] : null; + if (current_target) { + $(current_target).addClass('rsx-drop-target'); + } + }; + + // Clear all drag state + const clear_drag_state = () => { + drag_counter = 0; + $('.rsx-drop-active').removeClass('rsx-drop-active'); + update_target(null); + }; + + // dragenter - file enters the window + $(document).on('dragenter', function(e) { + if (!has_files(e)) return; + + drag_counter++; + + if (drag_counter === 1) { + // First entry - activate all droppables + const $droppables = get_visible_droppables(); + $droppables.addClass('rsx-drop-active'); + + // If only one droppable, auto-target it + if ($droppables.length === 1) { + update_target($droppables.first()); + } + } + }); + + // dragleave - file leaves an element + $(document).on('dragleave', function(e) { + if (!has_files(e)) return; + + drag_counter--; + + if (drag_counter <= 0) { + clear_drag_state(); + } + }); + + // dragover - file is over an element (fires continuously) + $(document).on('dragover', function(e) { + if (!has_files(e)) return; + + e.preventDefault(); // Required to allow drop + + const $droppables = get_visible_droppables(); + + if ($droppables.length === 0) { + // No drop targets - show no-drop cursor + e.originalEvent.dataTransfer.dropEffect = 'none'; + return; + } + + if ($droppables.length === 1) { + // Single target - already set, allow copy + e.originalEvent.dataTransfer.dropEffect = 'copy'; + return; + } + + // Multiple targets - find if we're over one + const $hovered = $(e.target).closest('.rsx-droppable'); + + if ($hovered.length && $hovered.hasClass('rsx-drop-active')) { + // Over a valid droppable + update_target($hovered); + e.originalEvent.dataTransfer.dropEffect = 'copy'; + } else { + // Not over any droppable - show no-drop cursor + update_target(null); + e.originalEvent.dataTransfer.dropEffect = 'none'; + } + }); + + // drop - file is dropped + $(document).on('drop', function(e) { + if (!has_files(e)) return; + + e.preventDefault(); + e.stopPropagation(); + + const files = e.originalEvent.dataTransfer.files; + + if (current_target && files.length > 0) { + // Trigger file-drop event on the component + const $target = $(current_target); + const component = $target.component(); + + if (component) { + component.trigger('file-drop', { + files: files, + dataTransfer: e.originalEvent.dataTransfer, + originalEvent: e.originalEvent + }); + } else { + // No component - trigger jQuery event on element directly + $target.trigger('file-drop', { + files: files, + dataTransfer: e.originalEvent.dataTransfer, + originalEvent: e.originalEvent + }); + } + } + + clear_drag_state(); + }); + + // Handle drag end (e.g., user presses Escape) + $(document).on('dragend', function(e) { + clear_drag_state(); + }); + } +} diff --git a/app/RSpade/man/file_drop.txt b/app/RSpade/man/droppable.txt similarity index 91% rename from app/RSpade/man/file_drop.txt rename to app/RSpade/man/droppable.txt index ac34908a9..51726b141 100755 --- a/app/RSpade/man/file_drop.txt +++ b/app/RSpade/man/droppable.txt @@ -1,24 +1,24 @@ NAME - File Drop Handler - Global drag-and-drop file interception system + Droppable - Global drag-and-drop file interception system SYNOPSIS Add class="rsx-droppable" to any element or component to receive dropped files via the file-drop event. DESCRIPTION - The RSpade framework provides a global file drop handler that intercepts - all file drag-and-drop operations at the document level. This system - routes dropped files to designated drop targets using CSS class-based + Droppable is the RSX framework's global file drop system. It intercepts + all file drag-and-drop operations at the document level and routes + dropped files to designated drop targets using CSS class-based registration. Unlike traditional HTML5 drag-and-drop which requires explicit event - handlers on each element, this system provides: + handlers on each element, Droppable provides: - Automatic visual feedback during file drags - Smart target selection (single vs multiple targets) - Cursor feedback indicating valid/invalid drop zones - Component event integration - The framework initializes this handler automatically during bootstrap. + Droppable initializes automatically during framework bootstrap. No manual initialization is required. CSS CLASSES @@ -111,7 +111,7 @@ FILE-DROP EVENT FILE VALIDATION - Each widget is responsible for validating dropped files. The framework + Each widget is responsible for validating dropped files. Droppable provides files without filtering. Common validation patterns: By MIME Type: @@ -161,7 +161,7 @@ FILE VALIDATION CURSOR FEEDBACK - The framework automatically sets dropEffect to control cursor appearance: + Droppable automatically sets dropEffect to control cursor appearance: - "copy" cursor: Shown when hovering over a valid drop target - "none" cursor: Shown when no valid target exists or when hovering @@ -172,12 +172,12 @@ CURSOR FEEDBACK IMPLEMENTATION NOTES Drag Counter: - The framework uses a drag counter to track when files enter and - leave the window. This handles the common issue where dragenter - and dragleave fire for child elements. + Droppable uses a drag counter to track when files enter and leave + the window. This handles the common issue where dragenter and + dragleave fire for child elements. Event Prevention: - The handler prevents default browser behavior for all file drags, + Droppable prevents default browser behavior for all file drags, ensuring files are never accidentally downloaded or opened. Cleanup: @@ -276,7 +276,7 @@ STYLING RECOMMENDATIONS background: rgba(0, 123, 255, 0.15); } -RSX VS HTML5 DRAG-DROP +DROPPABLE VS HTML5 DRAG-DROP Standard HTML5: - Must add dragenter, dragover, drop handlers to each element @@ -284,9 +284,9 @@ RSX VS HTML5 DRAG-DROP - Must track drag state per element - No automatic multi-target coordination - RSX: + Droppable: - Add rsx-droppable class, handle file-drop event - - Framework handles all drag events + - Droppable handles all drag events - Automatic state management and cleanup - Smart single vs multi-target behavior @@ -315,4 +315,4 @@ SEE ALSO VERSION RSpade Framework 1.0 - Last Updated: 2025-01-14 + Last Updated: 2026-01-15 diff --git a/app/RSpade/man/file_upload.txt b/app/RSpade/man/file_upload.txt index 949df1c99..d04ecc123 100755 --- a/app/RSpade/man/file_upload.txt +++ b/app/RSpade/man/file_upload.txt @@ -1330,6 +1330,7 @@ FUTURE DEVELOPMENT ✓ Session-based attachment security with can_user_assign_this_file() ✓ Attachment API (attach_to, add_to, detach) ✓ Model helper methods (get_attachment, get_attachments) + ✓ Droppable - Global drag-and-drop file interception system Planned Enhancements: @@ -1367,7 +1368,7 @@ FUTURE DEVELOPMENT - Automatic cleanup of unused thumbnails JQHTML Upload Widgets: - - Drag-and-drop file uploader component + ✓ Droppable - Global drag-and-drop file interception (see droppable.txt) - Multi-file upload queue with progress - Image cropper/editor widget - Camera capture widget @@ -1426,10 +1427,11 @@ SECURITY they own or have permission to view. SEE ALSO + droppable.txt - Global drag-and-drop file interception system model.txt - Model system documentation storage_directories.txt - Storage directory conventions migrations.txt - Database migration system VERSION RSpade Framework 1.0 - Last Updated: 2025-11-04 + Last Updated: 2026-01-15 diff --git a/app/RSpade/man/file_upload_examples.txt b/app/RSpade/man/file_upload_examples.txt index 33adda6e3..da5013667 100755 --- a/app/RSpade/man/file_upload_examples.txt +++ b/app/RSpade/man/file_upload_examples.txt @@ -385,6 +385,89 @@ DELETING FILES ->where('fileable_id', $project_id) ->delete(); +DRAG-AND-DROP UPLOADS WITH DROPPABLE + + Use Droppable to enable drag-and-drop file uploads in JQHTML components. + See droppable.txt for full documentation. + + Basic Integration: + + Template (My_Uploader.jqhtml): + + +
Drop files here or click to upload
+ + +
+ + JavaScript (My_Uploader.js): + + class My_Uploader extends Jqhtml_Component { + on_render() { + // Handle dropped files via Droppable + this.on('file-drop', (_, data) => { + this._upload_files(data.files); + }); + + // Handle click-to-upload + this.$.on('click', () => this.$sid('file_input').click()); + this.$sid('file_input').on('change', (e) => { + this._upload_files(e.target.files); + }); + } + + async _upload_files(files) { + for (let file of files) { + // Validate + if (file.size > 10 * 1024 * 1024) { + Flash.error(`${file.name} exceeds 10 MB limit`); + continue; + } + + // Upload + const formData = new FormData(); + formData.append('file', file); + + const response = await $.ajax({ + url: '/_upload', + type: 'POST', + data: formData, + processData: false, + contentType: false + }); + + if (response.success) { + this._add_to_list(response.attachment); + } + } + } + + _add_to_list(attachment) { + this.$sid('file_list').append( + `
  • ${attachment.file_name}
  • ` + ); + } + } + + SCSS (My_Uploader.scss): + + .My_Uploader { + border: 2px dashed #ccc; + padding: 20px; + text-align: center; + cursor: pointer; + + &.rsx-drop-active { + border-color: #007bff; + background: rgba(0, 123, 255, 0.05); + } + + &.rsx-drop-target { + border-style: solid; + background: rgba(0, 123, 255, 0.15); + } + } + SECURITY CONSIDERATIONS 1. Always validate files: @@ -426,9 +509,10 @@ SECURITY CONSIDERATIONS SEE ALSO file_upload.txt - Complete file upload system documentation + droppable.txt - Global drag-and-drop file interception system model.txt - Model system documentation routing.txt - Route and endpoint documentation VERSION RSpade Framework 1.0 - Last Updated: 2025-11-02 + Last Updated: 2026-01-15