Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 30 additions & 1 deletion config/diffyne.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
'cors' => [
'allowed_origins' => explode(',', env('DIFFYNE_WS_CORS_ORIGINS', '*')),
'allowed_methods' => ['GET', 'POST', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-CSRF-TOKEN'],
'allowed_headers' => ['Content-Type', 'Authorization'],
],
],

Expand Down Expand Up @@ -149,4 +149,33 @@
'snapshot_cache_size' => 100, // Max components to cache snapshots for
],

/*
|--------------------------------------------------------------------------
| File Upload Configuration
|--------------------------------------------------------------------------
|
| Configuration for file upload handling in Diffyne components.
| Files are stored temporarily and can be moved to permanent storage
| using the moveTemporaryFile() method in your components.
|
*/

'file_upload' => [
// Storage disk for temporary files
'disk' => env('DIFFYNE_FILE_DISK', 'local'),

// Path for temporary file storage (relative to disk root)
'temporary_path' => env('DIFFYNE_FILE_TEMP_PATH', 'diffyne/temp'),

// Maximum file size in KB (default: 12MB)
'max_size' => env('DIFFYNE_FILE_MAX_SIZE', 12288),

// Allowed MIME types (null = all types allowed)
// Example: ['image/jpeg', 'image/png', 'image/gif']
'allowed_mimes' => env('DIFFYNE_FILE_MIMES') ? explode(',', env('DIFFYNE_FILE_MIMES')) : null,

// Cleanup temporary files older than this many hours
'cleanup_after_hours' => env('DIFFYNE_FILE_CLEANUP_HOURS', 24),
],

];
2 changes: 1 addition & 1 deletion resources/dist/js/diffyne.js

Large diffs are not rendered by default.

59 changes: 58 additions & 1 deletion resources/js/Diffyne.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { LoadingService } from './services/LoadingService.js';
import { ErrorService } from './services/ErrorService.js';
import { ModelSyncService } from './services/ModelSyncService.js';
import { EventManager } from './services/EventManager.js';
import { FileUploadService } from './services/FileUploadService.js';
import { parseJSON, parseAction, updateQueryString, getQueryParams, Logger } from './utils/helpers.js';

export class Diffyne {
Expand All @@ -35,6 +36,7 @@ export class Diffyne {
this.errorService = new ErrorService();
this.modelSync = new ModelSyncService();
this.eventManager = new EventManager(this.registry, this.logger);
this.fileUpload = new FileUploadService(this.config);

// Request tracking for cancellation and sequencing
this.pendingRequests = new Map();
Expand All @@ -44,7 +46,8 @@ export class Diffyne {
this.eventBinder = new EventBinder(
(id, action, event) => this.handleAction(id, action, event),
(id, property, value) => this.handleModelUpdate(id, property, value),
(id, property, value) => this.updateLocalState(id, property, value)
(id, property, value) => this.updateLocalState(id, property, value),
(id, property, file, isMultiple) => this.handleFileUpload(id, property, file, isMultiple)
);

this.init();
Expand Down Expand Up @@ -234,6 +237,56 @@ export class Diffyne {
this.logger.log(`Local state update: ${property} = ${value}`);
}

/**
* Handle file upload
*/
async handleFileUpload(componentId, property, fileOrFiles, isMultiple = false) {
const component = this.registry.get(componentId);
if (!component) return;

try {
this.loadingService.show(component.element);

if (isMultiple && Array.isArray(fileOrFiles)) {
const identifiers = [];
for (const file of fileOrFiles) {
try {
const result = await this.fileUpload.uploadFile(file, componentId, property);
if (result.success) {
identifiers.push(result.identifier);
}
} catch (error) {
this.logger.error(`Failed to upload file ${file.name}:`, error);
}
}

if (identifiers.length > 0) {
const currentValue = component.state[property];
const existing = Array.isArray(currentValue) ? currentValue : [];
await this.updateProperty(componentId, property, [...existing, ...identifiers]);
}
}
else if (!isMultiple) {
const result = await this.fileUpload.uploadFile(fileOrFiles, componentId, property);

if (result.success) {
const currentValue = component.state[property];
if (Array.isArray(currentValue)) {
await this.updateProperty(componentId, property, [...currentValue, result.identifier]);
} else {
await this.updateProperty(componentId, property, result.identifier);
}
}
}
} catch (error) {
this.errorService.display(component.element, {
[property]: [error.message || 'Upload failed']
});
} finally {
this.loadingService.hide(component.element);
}
}

/**
* Call component method
*/
Expand Down Expand Up @@ -407,6 +460,10 @@ export class Diffyne {
if (propertyValue !== undefined) {
const modelInputs = this.modelSync.findModelInputs(component.element);
modelInputs.forEach(input => {
if (input.type === 'file') {
return;
}

const modelAttr = this.modelSync.findModelAttribute(input);
if (modelAttr) {
const property = input.getAttribute(modelAttr.name);
Expand Down
43 changes: 42 additions & 1 deletion resources/js/core/EventBinder.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
import { debounce } from '../utils/helpers.js';

export class EventBinder {
constructor(actionHandler, modelHandler, localStateHandler) {
constructor(actionHandler, modelHandler, localStateHandler, fileUploadHandler) {
this.actionHandler = actionHandler;
this.modelHandler = modelHandler;
this.localStateHandler = localStateHandler;
this.fileUploadHandler = fileUploadHandler;
}

/**
Expand All @@ -24,6 +25,7 @@ export class EventBinder {
this.bindClickEvents(element, componentId);
this.bindChangeEvents(element, componentId);
this.bindModelEvents(element, componentId);
this.bindFileEvents(element, componentId);
this.bindSubmitEvents(element, componentId);
this.bindPollEvents(element, componentId);
}
Expand Down Expand Up @@ -104,6 +106,12 @@ export class EventBinder {
// Change events
wrapper.addEventListener('change', (e) => {
const target = e.target;

// Skip file inputs - handled by bindFileEvents
if (target.type === 'file') {
return;
}

const modelAttr = this.findModelAttribute(target);

if (modelAttr) {
Expand All @@ -127,6 +135,39 @@ export class EventBinder {
});
}

/**
* Bind file upload events
*/
bindFileEvents(wrapper, componentId) {
wrapper.addEventListener('change', (e) => {
const target = e.target;
if (target.type !== 'file') return;

const modelAttr = this.findModelAttribute(target);
if (!modelAttr || !this.fileUploadHandler) return;

const property = target.getAttribute(modelAttr.name);
const modifiers = this.parseModifiers(modelAttr.name, property);
const files = target.files;

if (files && files.length > 0) {
const isMultiple = target.multiple;

if (isMultiple) {
const validFiles = Array.from(files).filter(file => file && file.size > 0);
if (validFiles.length > 0) {
this.fileUploadHandler(componentId, modifiers.property, validFiles, true);
}
} else if (files.length === 1) {
const file = files[0];
if (file && file.size > 0) {
this.fileUploadHandler(componentId, modifiers.property, file, false);
}
}
}
});
}

/**
* Bind submit events
*/
Expand Down
10 changes: 10 additions & 0 deletions resources/js/core/PatchApplier.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@ export class PatchApplier {
element.tagName === 'SELECT') && key === 'value') {
element.value = value;
}

// Handle checkbox checked attribute
if (element.tagName === 'INPUT' && element.type === 'checkbox' && key === 'checked') {
element.checked = true;
}
});

removeAttrs.forEach(key => {
Expand All @@ -293,6 +298,11 @@ export class PatchApplier {
element.tagName === 'SELECT') && key === 'value') {
element.value = '';
}

// Handle checkbox checked attribute removal
if (element.tagName === 'INPUT' && element.type === 'checkbox' && key === 'checked') {
element.checked = false;
}
});
}

Expand Down
112 changes: 99 additions & 13 deletions resources/js/core/VNodeConverter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,137 @@
*/

export class VNodeConverter {
/**
* Check if a tag name is an SVG element
*/
isSVGElement(tagName) {
const svgTags = new Set([
'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse',
'g', 'defs', 'use', 'text', 'tspan', 'image', 'foreignobject',
'clippath', 'mask', 'pattern', 'lineargradient', 'radialgradient', 'stop',
'animate', 'animatetransform', 'animatemotion', 'title', 'desc', 'metadata'
]);
return svgTags.has(tagName.toLowerCase());
}

/**
* Normalize SVG attribute names to their correct case
* SVG attributes are case-sensitive (e.g., viewBox not viewbox)
*/
normalizeSVGAttributeName(key) {
const svgAttributeMap = {
'viewbox': 'viewBox',
'preserveaspectratio': 'preserveAspectRatio',
'gradientunits': 'gradientUnits',
'gradienttransform': 'gradientTransform',
'xlink:href': 'xlink:href',
'xlink:title': 'xlink:title',
'stroke-linecap': 'stroke-linecap',
'stroke-linejoin': 'stroke-linejoin',
'stroke-width': 'stroke-width',
'stroke-dasharray': 'stroke-dasharray',
'stroke-dashoffset': 'stroke-dashoffset',
'fill-rule': 'fill-rule',
'clip-path': 'clip-path',
'clip-rule': 'clip-rule',
'text-anchor': 'text-anchor',
'dominant-baseline': 'dominant-baseline',
'baseline-shift': 'baseline-shift',
'alignment-baseline': 'alignment-baseline',
'font-family': 'font-family',
'font-size': 'font-size',
'font-weight': 'font-weight',
'font-style': 'font-style',
'text-decoration': 'text-decoration',
'letter-spacing': 'letter-spacing',
'word-spacing': 'word-spacing',
'text-transform': 'text-transform',
'writing-mode': 'writing-mode',
'glyph-orientation-vertical': 'glyph-orientation-vertical',
'glyph-orientation-horizontal': 'glyph-orientation-horizontal',
};

const lowerKey = key.toLowerCase();
return svgAttributeMap[lowerKey] || key;
}

/**
* Convert VNode to actual DOM node
*/
vnodeToDOM(vnode) {
vnodeToDOM(vnode, parentNamespace = null) {
// Text node (minified)
if (vnode.x !== undefined) {
return document.createTextNode(vnode.x);
}

// Comment node (minified)
if (vnode.m !== undefined) {
return document.createComment(vnode.m);
}

// Element node (minified)
if (vnode.t) {
const element = document.createElement(vnode.t);

const tagName = vnode.t.toLowerCase();
const isSVG = this.isSVGElement(tagName);
const namespace = isSVG ? 'http://www.w3.org/2000/svg' : parentNamespace;

// createElementNS requires lowercase tag names
const element = namespace
? document.createElementNS(namespace, tagName)
: document.createElement(tagName);

const attrs = vnode.a || vnode.attributes || {};
Object.entries(attrs).forEach(([key, value]) => {
element.setAttribute(key, value);
if (namespace) {
// Normalize SVG attribute names to correct case (e.g., viewBox not viewbox)
const normalizedKey = this.normalizeSVGAttributeName(key);
// For SVG elements, use setAttributeNS with null namespace
// This ensures proper attribute handling for SVG
element.setAttributeNS(null, normalizedKey, String(value));
} else {
element.setAttribute(key, String(value));
}
});

const children = vnode.c || vnode.children || [];
const childNamespace = isSVG ? 'http://www.w3.org/2000/svg' : namespace;
children.forEach(child => {
element.appendChild(this.vnodeToDOM(child));
element.appendChild(this.vnodeToDOM(child, childNamespace));
});

return element;
}

// Legacy format fallback
if (vnode.type === 'text') {
return document.createTextNode(vnode.text);
}

if (vnode.type === 'element') {
const element = document.createElement(vnode.tag);
const tagName = vnode.tag.toLowerCase();
const isSVG = this.isSVGElement(tagName);
const namespace = isSVG ? 'http://www.w3.org/2000/svg' : parentNamespace;

// createElementNS requires lowercase tag names
const element = namespace
? document.createElementNS(namespace, tagName)
: document.createElement(tagName);

Object.entries(vnode.attributes || {}).forEach(([key, value]) => {
element.setAttribute(key, value);
if (namespace) {
// Normalize SVG attribute names to correct case (e.g., viewBox not viewbox)
const normalizedKey = this.normalizeSVGAttributeName(key);
// For SVG elements, use setAttributeNS with null namespace
// This ensures proper attribute handling for SVG
element.setAttributeNS(null, normalizedKey, String(value));
} else {
element.setAttribute(key, String(value));
}
});

const childNamespace = isSVG ? 'http://www.w3.org/2000/svg' : namespace;
(vnode.children || []).forEach(child => {
element.appendChild(this.vnodeToDOM(child));
element.appendChild(this.vnodeToDOM(child, childNamespace));
});

return element;
Expand Down
Loading