Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PR] Refactoring and fixing iOS Safari issue #64

Merged
merged 11 commits into from
May 9, 2024
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tweakpane-plugin-file-import",
"version": "1.0.1",
"version": "1.0.2",
"description": "A general-purpose and simple file input Tweakpane plugin",
"homepage": "https://github.com/LuchoTurtle/tweakpane-plugin-file-import",
"main": "dist/tweakpane-plugin-file-import.js",
Expand Down
248 changes: 174 additions & 74 deletions src/controller/controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
Controller,
PointerHandler,
PointerHandlerEvent,
Value,
ViewProps,
} from '@tweakpane/core';
import {Controller, Value, ViewProps} from '@tweakpane/core';

import {FilePluginView} from '../view/view.js';

Expand All @@ -18,101 +12,207 @@ interface Config {

export class FilePluginController implements Controller<FilePluginView> {
public readonly value: Value<File | null>;
private readonly filetypes?: string[];

public readonly view: FilePluginView;
public readonly viewProps: ViewProps;

constructor(doc: Document, config: Config) {
// Binding click event handlers
this.onContainerClick_ = this.onContainerClick_.bind(this);
this.onButtonClick_ = this.onButtonClick_.bind(this);
private readonly config: Config;

// Receive the bound value from the plugin
constructor(doc: Document, config: Config) {
this.value = config.value;

// Get filetypes
this.filetypes = config.filetypes;

// and also view props
this.viewProps = config.viewProps;
this.viewProps.handleDispose(() => {
// Called when the controller is disposing
});

// Create a custom view
this.view = new FilePluginView(doc, {
value: this.value,
viewProps: this.viewProps,
value: config.value,
lineCount: config.lineCount,
filetypes: config.filetypes,
});
this.config = config;

// Bind event handlers
this.onFile = this.onFile.bind(this);
this.onDrop = this.onDrop.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.onDragLeave = this.onDragLeave.bind(this);
this.onDeleteClick = this.onDeleteClick.bind(this);

this.view.input.addEventListener('change', this.onFile);
this.view.element.addEventListener('drop', this.onDrop);
this.view.element.addEventListener('dragover', this.onDragOver);
this.view.element.addEventListener('dragleave', this.onDragLeave);
this.view.deleteButton.addEventListener('click', this.onDeleteClick);

// You can use `PointerHandler` to handle pointer events in the same way as Tweakpane do
const containerPtHandler = new PointerHandler(this.view.container);
containerPtHandler.emitter.on('down', this.onContainerClick_);
this.value.emitter.on('change', () => this.handleValueChange());

const buttonPtHandler = new PointerHandler(this.view.button);
buttonPtHandler.emitter.on('down', this.onButtonClick_);
// Dispose event handlers
this.viewProps.handleDispose(() => {
this.view.input.removeEventListener('change', this.onFile);
this.view.element.removeEventListener('drop', this.onDrop);
this.view.element.removeEventListener('dragover', this.onDragOver);
this.view.element.removeEventListener('dragleave', this.onDragLeave);
this.view.deleteButton.removeEventListener('click', this.onDeleteClick);
});
}

/**
* Event handler when the container HTML element is clicked.
* It checks if the filetype is valid and if the user has chosen a file.
* If the file is valid, the `rawValue` of the controller is set.
* @param ev Pointer event.
* Called when the value of the input changes.
* @param event change event.
*/
private onContainerClick_(_ev: PointerHandlerEvent) {
// Accepted filetypes
const filetypes = this.filetypes;

// Creates hidden `input` and mimicks click to open file explorer
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.style.opacity = '0';
input.style.position = 'fixed';
input.style.pointerEvents = 'none';
document.body.appendChild(input);

// Adds event listener when user chooses file
input.addEventListener(
'input',
(_ev) => {
// Check if user has chosen a file
if (input.files && input.files.length > 0) {
const file = input.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();

// Check if filetype is allowed
if (
filetypes &&
filetypes.length > 0 &&
!filetypes.includes(fileExtension) &&
fileExtension
) {
return;
} else {
this.value.setRawValue(file);
}
}
},
{once: true},
);
private onFile(_event: Event): void {
const input = this.view.input;

// Check if user has chosen a file.
// If it's valid, we update the value. Otherwise, show warning.
if (input.files && input.files.length > 0) {
const file = input.files[0];
if (!this.isFileValid(file)) {
this.showWarning();
} else {
this.value.setRawValue(file);
}
}
}

// Click hidden input to open file explorer and remove it
input.click();
document.body.removeChild(input);
/**
* Shows warning text for 5 seconds.
*/
private showWarning() {
this.view.warning.style.display = 'block';
this.view.warning.innerHTML = 'Unaccepted file type.';
setTimeout(() => {
// Resetting warning text
this.view.warning.innerHTML = '';
this.view.warning.style.display = 'none';
}, 5000);
}

/**
* Checks if the file is valid with the given filetypes.
* @param file File object
* @returns true if the file is valid.
*/
private isFileValid(file: File): boolean {
const filetypes = this.config.filetypes;
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
return !(
filetypes &&
filetypes.length > 0 &&
!filetypes.includes(fileExtension) &&
fileExtension
);
}

/**
* Event handler when the delete HTML button is clicked.
* It resets the `rawValue` of the controller.
* @param ev Pointer event.
*/
private onButtonClick_(_ev: PointerHandlerEvent) {
private onDeleteClick() {
const file = this.value.rawValue;

if (file) {
// Resetting the value
this.value.setRawValue(null);

// Resetting the input
this.view.input.value = '';

// Resetting the warning text
this.view.warning.innerHTML = '';
this.view.warning.style.display = 'none';
}
}

/**
* Called when the user drags over a file.
* Updates the style of the container.
* @param event drag event.
*/
private onDragOver(event: Event) {
event.preventDefault();
this.view.changeDraggingState(true);
}

/**
* Called when the user leaves the container while dragging.
* Updates the style of the container.
*/
private onDragLeave() {
this.view.changeDraggingState(false);
}

/**
* Called when the user drops a file in the container.
* Either shows a warning if it's invalid or updates the value if it's valid.
* @param ev drag event.
*/
private onDrop(ev: DragEvent) {
if (ev instanceof DragEvent) {
// Prevent default behavior (Prevent file from being opened)
ev.preventDefault();

if (ev.dataTransfer) {
if (ev.dataTransfer.files) {
// We only change the value if the user has dropped a single file
const filesArray = [ev.dataTransfer.files][0];
if (filesArray.length == 1) {
const file = filesArray.item(0);
if (file) {
if (!this.isFileValid(file)) {
this.showWarning();
} else {
this.value.setRawValue(file);
}
}
}
}
}
}
this.view.changeDraggingState(false);
}

/**
* Called when the value (bound to the controller) changes (e.g. when the file is selected).
*/
private handleValueChange() {
const fileObj = this.value.rawValue;

const containerEl = this.view.container;
const textEl = this.view.text;
const fileIconEl = this.view.fileIcon;
const deleteButton = this.view.deleteButton;

if (fileObj) {
// Setting the text of the file to the element
textEl.textContent = fileObj.name;

// Removing icon and adding text
containerEl.appendChild(textEl);
if (containerEl.contains(fileIconEl)) {
containerEl.removeChild(fileIconEl);
}

// Resetting warning text
this.view.warning.innerHTML = '';
this.view.warning.style.display = 'none';

// Adding button to delete
deleteButton.style.display = 'block';
containerEl.style.border = 'unset';
} else {
// Setting the text of the file to the element
textEl.textContent = '';

// Removing text and adding icon
containerEl.appendChild(fileIconEl);
containerEl.removeChild(textEl);

// Resetting warning text
this.view.warning.innerHTML = '';
this.view.warning.style.display = 'none';

// Hiding button and resetting border
deleteButton.style.display = 'none';
containerEl.style.border = '1px dashed #717070';
}
}
}
32 changes: 31 additions & 1 deletion src/sass/plugin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
overflow: hidden;
position: relative;

height: 100%;
width: 100%;

border: 1px dashed #717070;
-webkit-border-radius: 5px;
border-radius: 5px;
Expand All @@ -21,6 +24,28 @@
opacity: 0.5;
}

&_input_area_dragging {
border: 1px dashed rgb(103 116 255);
background-color: #5858b93b;
}

&_warning {
color: tp.cssVar('input-fg');
bottom: 2px;
display: inline-block;
font-size: 0.9em;
height: max-content;
line-height: 0.9;
margin: 0.2rem;
opacity: 0.5;
white-space: normal;
width: max-content;
word-wrap: break-word;
text-align: right;
width: 100%;
margin-top: var(--cnt-vp);
}

&_text {
// You can use CSS variables for styling. See declarations for details:
// ../../node_modules/@tweakpane/core/lib/sass/common/_defs.scss
Expand Down Expand Up @@ -57,7 +82,7 @@
// https://css.gg/software-upload
&_icon {
box-sizing: border-box;
position: relative;
position: absolute;
display: block;
transform: scale(var(--ggs, 1));
width: 16px;
Expand Down Expand Up @@ -102,3 +127,8 @@
.#{tp.$prefix}-btnv_b {
margin-top: 10px;
}

// Styles for the input
.#{tp.$prefix}-inputv {
opacity: 0;
}
Loading
Loading