From 7a4a0dcf8585205aa3d907bfab499269dd121ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 27 Dec 2024 23:56:37 +0100 Subject: [PATCH] Enable file uploading by drag&drop --- .../plugin.php | 111 ++++++++++++++++ .../src/components/FilePickerTree/index.tsx | 118 +++++++++++++++--- .../ui/src/components/FilePickerTree/types.ts | 11 ++ .../ui/src/index.tsx | 61 +++++---- 4 files changed, 260 insertions(+), 41 deletions(-) create mode 100644 packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/types.ts diff --git a/packages/playground/data-liberation-static-files-editor/plugin.php b/packages/playground/data-liberation-static-files-editor/plugin.php index 9a96faee1a..83114a6fb7 100644 --- a/packages/playground/data-liberation-static-files-editor/plugin.php +++ b/packages/playground/data-liberation-static-files-editor/plugin.php @@ -171,6 +171,14 @@ static public function initialize() { return current_user_can('edit_posts'); }, )); + + register_rest_route('static-files-editor/v1', '/create-files', array( + 'methods' => 'POST', + 'callback' => array(self::class, 'create_files_endpoint'), + 'permission_callback' => function() { + return current_user_can('edit_posts'); + }, + )); }); // @TODO: the_content and rest_prepare_local_file filters run twice for REST API requests. @@ -613,6 +621,109 @@ static public function move_file_endpoint($request) { return array('success' => true); } + static public function create_files_endpoint($request) { + $path = $request->get_param('path'); + $nodes_json = $request->get_param('nodes'); + + if(!$path) { + $path = '/'; + } + + if (!$nodes_json) { + return new WP_Error('invalid_tree', 'Invalid file tree structure'); + } + + $nodes = json_decode($nodes_json, true); + if (!$nodes) { + return new WP_Error('invalid_json', 'Invalid JSON structure'); + } + + $created_files = []; + + try { + $fs = self::get_fs(); + foreach ($nodes as $node) { + $result = self::process_node($node, $path, $fs, $request); + if (is_wp_error($result)) { + return $result; + } + $created_files = array_merge($created_files, $result); + } + + return array( + 'created_files' => $created_files + ); + } catch (Exception $e) { + return new WP_Error('creation_failed', $e->getMessage()); + } + } + + static private function process_node($node, $parent_path, $fs, $request) { + if (!isset($node['name']) || !isset($node['type'])) { + return new WP_Error('invalid_node', 'Invalid node structure'); + } + + $path = rtrim($parent_path, '/') . '/' . ltrim($node['name'], '/'); + $created_files = []; + + if ($node['type'] === 'folder') { + if (!$fs->mkdir($path)) { + return new WP_Error('mkdir_failed', "Failed to create directory: $path"); + } + + if (!empty($node['children'])) { + foreach ($node['children'] as $child) { + $result = self::process_node($child, $path, $fs, $request); + if (is_wp_error($result)) { + return $result; + } + $created_files = array_merge($created_files, $result); + } + } + } else { + $content = ''; + if (isset($node['content']) && is_string($node['content']) && strpos($node['content'], '@file:') === 0) { + $file_key = substr($node['content'], 6); + $uploaded_file = $request->get_file_params()[$file_key] ?? null; + if ($uploaded_file && $uploaded_file['error'] === UPLOAD_ERR_OK) { + $content = file_get_contents($uploaded_file['tmp_name']); + } + } + + if (!$fs->put_contents($path, $content)) { + return new WP_Error('write_failed', "Failed to write file: $path"); + } + + /* + // @TODO: Should we create posts here? + // * We'll reindex the data later anyway and create those posts on demand. + // * ^ yes, but this means we don't have these posts in the database right after the upload. + // * But if we do create them, how do we know which files need a post, and which ones are + // images, videos, etc? + $post_data = array( + 'post_title' => basename($path), + 'post_type' => WP_LOCAL_FILE_POST_TYPE, + 'post_status' => 'publish', + 'meta_input' => array( + 'local_file_path' => $path + ) + ); + $post_id = wp_insert_post($post_data); + + if (is_wp_error($post_id)) { + return $post_id; + } + */ + + $created_files[] = array( + 'path' => $path, + // 'post_id' => $post_id + ); + } + + return $created_files; + } + } WP_Static_Files_Editor_Plugin::initialize(); diff --git a/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/index.tsx b/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/index.tsx index 6763220bc5..f321462bff 100644 --- a/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/index.tsx +++ b/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/index.tsx @@ -1,4 +1,5 @@ -import React, { +import React from 'react'; +import { useEffect, useRef, useState, @@ -19,23 +20,32 @@ import '@wordpress/components/build-style/style.css'; import css from './style.module.css'; import classNames from 'classnames'; import { folder, file } from '../icons'; +import { FileTree } from './types'; export type FileNode = { name: string; type: 'file' | 'folder'; children?: FileNode[]; + content?: File; }; -export type CreatedNode = { - type: 'file' | 'folder'; - path: string; -}; +export type CreatedNode = + | { + type: 'file' | 'folder'; + path: string; + content?: string | ArrayBuffer | File; + } + | { + type: 'tree'; + path: string; + content: FileNode[]; + }; export type FilePickerControlProps = { files: FileNode[]; initialPath?: string; onSelect?: (path: string, node: FileNode) => void; - onNodeCreated?: (node: CreatedNode) => void; + onNodesCreated?: (tree: FileTree) => void; onNodeDeleted?: (path: string) => void; onNodeMoved?: ({ fromPath, @@ -61,6 +71,7 @@ type DragState = { path: string; hoverPath: string | null; hoverType: 'file' | 'folder' | null; + isExternal?: boolean; }; type FilePickerContextType = { @@ -93,8 +104,8 @@ export const FilePickerTree: React.FC = ({ files, initialPath, onSelect = () => {}, - onNodeCreated = (...args) => { - console.log('onNodeCreated', args); + onNodesCreated = (tree: FileTree) => { + console.log('onNodesCreated', tree); }, onNodeDeleted = (path: string) => { console.log('onNodeDeleted', path); @@ -206,13 +217,15 @@ export const FilePickerTree: React.FC = ({ toPath, }); } else { - const fullPath = `${editedNode.parentPath}/${name}`.replace( - /\/+/g, - '/' - ); - onNodeCreated({ - type: editedNode.type, - path: fullPath, + onNodesCreated({ + path: editedNode.parentPath, + nodes: [ + { + name: name, + type: editedNode.type, + content: null, + }, + ], }); } }; @@ -226,6 +239,7 @@ export const FilePickerTree: React.FC = ({ path, hoverPath: null, hoverType: null, + isExternal: false, }); }; @@ -239,6 +253,19 @@ export const FilePickerTree: React.FC = ({ type: 'file' | 'folder' ) => { e.preventDefault(); + + // Handle external files being dragged in + if (e.dataTransfer.types.includes('Files')) { + e.dataTransfer.dropEffect = 'copy'; + setDragState({ + path: '', + hoverPath: path, + hoverType: type, + isExternal: true, + }); + return; + } + if (dragState && dragState.path !== path) { // Prevent dropping a folder into its own descendant if (dragState.path && isDescendantPath(dragState.path, path)) { @@ -255,12 +282,71 @@ export const FilePickerTree: React.FC = ({ } }; - const handleDrop = ( + const handleDrop = async ( e: React.DragEvent, targetPath: string, targetType: 'file' | 'folder' ) => { e.preventDefault(); + // Handle file/directory upload + if (e.dataTransfer.items.length > 0) { + const targetFolder = + targetType === 'folder' + ? targetPath + : targetPath.split('/').slice(0, -1).join('/'); + const items = Array.from(e.dataTransfer.items); + + const buildTree = async ( + entry: FileSystemEntry, + parentPath: string = '' + ): Promise => { + if (entry.isFile) { + const fileEntry = entry as FileSystemFileEntry; + const file = await new Promise((resolve) => + fileEntry.file(resolve) + ); + return { + name: entry.name, + type: 'file', + content: file, + }; + } else { + const dirEntry = entry as FileSystemDirectoryEntry; + const reader = dirEntry.createReader(); + const entries = await new Promise( + (resolve) => { + reader.readEntries((entries) => resolve(entries)); + } + ); + + const children = await Promise.all( + entries.map((entry) => buildTree(entry)) + ); + + return { + name: entry.name, + type: 'folder', + children, + }; + } + }; + + const rootNodes = await Promise.all( + items + .map((item) => item.webkitGetAsEntry()) + .filter((entry): entry is FileSystemEntry => entry !== null) + .map((entry) => buildTree(entry)) + ); + + onNodesCreated({ + path: targetFolder, + nodes: rootNodes, + }); + + setDragState(null); + return; + } + if (dragState) { // Prevent dropping a folder into its own descendant if ( diff --git a/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/types.ts b/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/types.ts new file mode 100644 index 0000000000..259a28b864 --- /dev/null +++ b/packages/playground/data-liberation-static-files-editor/ui/src/components/FilePickerTree/types.ts @@ -0,0 +1,11 @@ +export type FileNode = { + name: string; + type: 'file' | 'folder'; + children?: FileNode[]; + content?: File; +}; + +export type FileTree = { + path: string; + nodes: FileNode[]; +}; diff --git a/packages/playground/data-liberation-static-files-editor/ui/src/index.tsx b/packages/playground/data-liberation-static-files-editor/ui/src/index.tsx index 9a83992b4d..fc4d7014c7 100644 --- a/packages/playground/data-liberation-static-files-editor/ui/src/index.tsx +++ b/packages/playground/data-liberation-static-files-editor/ui/src/index.tsx @@ -3,6 +3,7 @@ import { CreatedNode, FileNode, FilePickerTree, + FileTree, } from './components/FilePickerTree'; import { store as editorStore } from '@wordpress/editor'; import { store as preferencesStore } from '@wordpress/preferences'; @@ -98,37 +99,47 @@ function ConnectedFilePickerTree() { }); }; - const handleNodeCreated = async (node: CreatedNode) => { - if (node.type === 'file') { - await createEmptyFile(node.path); - } else if (node.type === 'folder') { - // Create an empty .gitkeep file in the new directory - // to make sure it will actually be created in the filesystem. - // @TODO: Rethink this approach. Ideally we could just display the - // directory in the tree, and let the user create files inside it. - await createEmptyFile(node.path + '/.gitkeep'); - } - }; - - const createEmptyFile = async (newFilePath: string) => { + const handleNodesCreated = async (tree: FileTree) => { try { + const formData = new FormData(); + formData.append('path', tree.path); + + // Convert nodes to JSON, but extract files to separate form fields + const processNode = (node: FileNode, prefix: string): any => { + const nodeData = { ...node }; + if (node.content instanceof File) { + formData.append(`${prefix}_content`, node.content); + nodeData.content = `@file:${prefix}_content`; + } + if (node.children) { + nodeData.children = node.children.map((child, index) => + processNode(child, `${prefix}_${index}`) + ); + } + return nodeData; + }; + + const processedNodes = tree.nodes.map((node, index) => + processNode(node, `file_${index}`) + ); + formData.append('nodes', JSON.stringify(processedNodes)); + const response = (await apiFetch({ - path: '/static-files-editor/v1/get-or-create-post-for-file', + path: '/static-files-editor/v1/create-files', method: 'POST', - data: { - path: newFilePath, - create_file: true, - }, - })) as { post_id: string }; + body: formData, + })) as { created_files: Array<{ path: string; post_id: string }> }; await refreshFileTree(); - onNavigateToEntityRecord({ - postId: response.post_id, - postType: WP_LOCAL_FILE_POST_TYPE, - }); + if (response.created_files.length > 0) { + onNavigateToEntityRecord({ + postId: response.created_files[0].post_id, + postType: WP_LOCAL_FILE_POST_TYPE, + }); + } } catch (error) { - console.error('Failed to create file:', error); + console.error('Failed to create files:', error); } }; @@ -169,7 +180,7 @@ function ConnectedFilePickerTree() { files={fileTree} onSelect={handleFileClick} initialPath={selectedPath} - onNodeCreated={handleNodeCreated} + onNodesCreated={handleNodesCreated} onNodeDeleted={handleNodeDeleted} onNodeMoved={handleNodeMoved} />