Skip to content

Commit

Permalink
fix(widgets): Added templating features
Browse files Browse the repository at this point in the history
  • Loading branch information
Lasse-numerous committed Dec 6, 2024
1 parent 0eb3d1a commit 4281ae7
Show file tree
Hide file tree
Showing 14 changed files with 519 additions and 23 deletions.
72 changes: 72 additions & 0 deletions js/src/components/ui/FileLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as React from "react";
import { Tooltip } from './Tooltip';

interface FileLoaderProps {
uiLabel: string;
uiTooltip: string;
accept: string;
onFileLoad: (content: Uint8Array, filename: string, encoding: string) => void;
encoding: string;
}

export function FileLoader({ uiLabel, uiTooltip, accept, onFileLoad }: FileLoaderProps) {
const fileInputRef = React.useRef<HTMLInputElement>(null);

const handleClick = () => {
fileInputRef.current?.click();
};

const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;

try {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);

// Detect encoding from the file content
const detectedEncoding = detectEncoding(uint8Array);
console.log(file.name);
console.log(detectedEncoding);

// Pass both content and detected encoding back
onFileLoad(uint8Array, file.name, detectedEncoding);
} catch (error) {
console.error('Error loading file:', error);
}
};

// Simple encoding detection function
const detectEncoding = (data: Uint8Array): string => {
// Check for UTF-8 BOM
if (data.length >= 3 && data[0] === 0xEF && data[1] === 0xBB && data[2] === 0xBF) {
return 'utf-8';
}
// Check for UTF-16 LE BOM
if (data.length >= 2 && data[0] === 0xFF && data[1] === 0xFE) {
return 'utf-16le';
}
// Check for UTF-16 BE BOM
if (data.length >= 2 && data[0] === 0xFE && data[1] === 0xFF) {
return 'utf-16be';
}
// Default to UTF-8
return 'utf-8';
};

return (
<div className="file-loader-container">
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept={accept}
onChange={handleFileChange}
/>
<button onClick={handleClick} className="file-loader-button">
{uiLabel}
{uiTooltip && <Tooltip tooltip={uiTooltip} />}
</button>
</div>
);
}
113 changes: 113 additions & 0 deletions js/src/components/ui/FileSaver.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { Tooltip } from './Tooltip';

interface SaveFilePickerOptions {
suggestedName?: string;
types?: Array<{
description: string;
accept: Record<string, string[]>;
}>;
}

declare global {
interface Window {
showSaveFilePicker(options?: SaveFilePickerOptions): Promise<FileSystemFileHandle>;
}
}

interface FileSaverProps {
uiLabel: string;
uiTooltip: string;
content: Uint8Array;
suggestedFilename?: string;
mimeType?: string;
fileExtension?: string;
}

export function FileSaver({
uiLabel,
uiTooltip,
content,
suggestedFilename,
mimeType = 'application/octet-stream',
fileExtension = ''
}: FileSaverProps) {
const [isInstalled, setIsInstalled] = useState<boolean>(false);

useEffect(() => {
const checkInstallStatus = async () => {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
const isInWebAPKMode = window.matchMedia('(display-mode: minimal-ui)').matches;
const isServiceWorkerRegistered = await navigator.serviceWorker.getRegistration() !== undefined;

let isInstalledApp = false;
if ('getInstalledRelatedApps' in navigator) {
const installedApps = await (navigator as any).getInstalledRelatedApps();
isInstalledApp = installedApps.length > 0;
}

setIsInstalled(isStandalone || isInWebAPKMode || isServiceWorkerRegistered || isInstalledApp);
};

checkInstallStatus();
window.addEventListener('resize', checkInstallStatus);

return () => {
window.removeEventListener('resize', checkInstallStatus);
};
}, []);

const handleClick = async () => {
if (isInstalled && 'showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: suggestedFilename,
types: [{
description: 'File',
accept: {
[mimeType]: [fileExtension]
}
}]
});

const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Error saving file:', err);
// Fallback to legacy method if modern method fails
useLegacySaveMethod();
}
}
} else {
useLegacySaveMethod();
}
};

const useLegacySaveMethod = () => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = suggestedFilename || 'download' + fileExtension;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

return (
<div className="file-saver-container">
<button
onClick={handleClick}
className="file-saver-button"
disabled={!content}
>
{uiLabel}
{uiTooltip && <Tooltip tooltip={uiTooltip} />}
</button>
</div>
);
}
2 changes: 1 addition & 1 deletion js/src/components/ui/MapSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,6 @@ export function MapSelector({ points = {}, value, center = [0, 0], zoom, onChang
}, []);

return (
<div ref={mapRef} style={{ width: '100%', height: '300px' }} />
<div ref={mapRef} style={{ width: '100%', height: '50vh' }} />
);
}
36 changes: 36 additions & 0 deletions js/src/components/widgets/FileLoaderWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from "react";
import { createRender, useModelState } from "@anywidget/react";
import { FileLoader } from "../ui/FileLoader";
import '../../css/styles.css';

function FileLoaderWidget() {
const [uiLabel] = useModelState<string>("ui_label");
const [uiTooltip] = useModelState<string>("ui_tooltip");
const [accept] = useModelState<string>("accept");
const [, setFileContent] = useModelState<Uint8Array | null>("file_content");
const [, setFilename] = useModelState<string | null>("filename");
const [, setEncoding] = useModelState<string>("encoding");

const handleFileLoad = (content: Uint8Array, filename: string, encoding: string) => {
console.log("FileLoaderWidget");
console.log(filename);
console.log(encoding);
console.log(content);
setFileContent(content);
setFilename(filename);
setEncoding(encoding);
};

return (
<FileLoader
uiLabel={uiLabel}
uiTooltip={uiTooltip}
accept={accept}
onFileLoad={handleFileLoad}
/>
);
}

export default {
render: createRender(FileLoaderWidget)
}
24 changes: 24 additions & 0 deletions js/src/components/widgets/FileSaverWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from "react";
import { createRender, useModelState } from "@anywidget/react";
import { FileSaver } from "../ui/FileSaver";
import '../../css/styles.css';

function FileSaverWidget() {
const [uiLabel] = useModelState<string>("ui_label");
const [uiTooltip] = useModelState<string>("ui_tooltip");
const [content] = useModelState<Uint8Array | null>("content");
const [suggestedFilename] = useModelState<string | null>("suggested_filename");

return (
<FileSaver
uiLabel={uiLabel}
uiTooltip={uiTooltip}
content={content}
suggestedFilename={suggestedFilename}
/>
);
}

export default {
render: createRender(FileSaverWidget)
}
37 changes: 33 additions & 4 deletions js/src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1328,14 +1328,43 @@ select::-ms-expand {
color: #ef4444;
}

.container {
display: block;
.widget-container {
display: flex;
width: 100%;
box-sizing: border-box;
margin: 0;
margin-top: 0px;
margin-bottom: 0px;
margin-left: 0px;
margin-right: 0px;
padding: 0px;
}

.container.hidden {
.widget-container.hidden {
display: none;
}

.file-loader-container {
display: inline-block;
}

.file-loader-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 4px;
background: #f8f8f8;
cursor: pointer;
}

.file-loader-button:hover {
background: #eee;
}

.widget-container-bordered {
border: 1px solid #3d3d3d;
border-radius: 4px;
}


5 changes: 5 additions & 0 deletions js/src/types/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
interface Window {
showSaveFilePicker(options?: {
suggestedName?: string;
}): Promise<FileSystemFileHandle>;
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "numerous-widgets"
version = "0.0.17"
dependencies = ["anywidget", "pydantic"]
dependencies = ["anywidget", "pydantic", "jinja2"]
classifiers = [
"Programming Language :: Python :: 3",
"Development Status :: 2 - Pre-Alpha",
Expand Down
55 changes: 55 additions & 0 deletions python/examples/marimo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,60 @@ def __():
return


@app.cell
def __(wi):
loader = wi.FileLoader(
label="Load CSV",
tooltip="Click to load a CSV file",
accept=".csv"
)
return (loader,)


@app.cell
def __(aw, loader):
loader_widget = aw(loader)
loader_widget
return (loader_widget,)


@app.cell
def __():
#with loader_widget.open("r") as f:
# print(f.read())
return


@app.cell
def __(loader, loader_widget):
if loader_widget.file_content:
print(loader.as_buffer.read())
return


@app.cell
def __(loader, loader_widget):
if loader_widget.file_content:
_str = loader.as_string.read()
print(_str)
return


@app.cell
def __(aw, loader_widget):
from numerous.widgets.files.load_save_from_local import FileSaver

# Create a saver widget
saver = FileSaver(label="Save File", tooltip="Click to save file")

if loader_widget.file_content:
# Update content when needed
saver.update_content(loader_widget.file_content, "hello.txt")

saver_widget = aw(saver)
saver_widget
return FileSaver, saver, saver_widget


if __name__ == "__main__":
app.run()
2 changes: 2 additions & 0 deletions python/src/numerous/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

from .task.process_task import process_task_control, ProcessTask, SubprocessTask, run_in_subprocess, sync_with_task

from .files.load_save_from_local import FileLoader
from .templating import render_template
try:
import numerous
from .numerous.project import ProjectsMenu
Expand Down
Loading

0 comments on commit 4281ae7

Please sign in to comment.