@webhandle/tree-file-browser 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +144 -0
  2. package/client-lib/base-image-name.mjs +41 -0
  3. package/client-lib/data-to-image.mjs +45 -0
  4. package/client-lib/dynamic-load.mjs +21 -0
  5. package/client-lib/file-select-dialog.mjs +70 -0
  6. package/client-lib/form-answer-dialog.mjs +65 -0
  7. package/client-lib/format-bytes.mjs +10 -0
  8. package/client-lib/get-extension-from-mime.mjs +17 -0
  9. package/client-lib/get-file-image-stats.mjs +17 -0
  10. package/client-lib/get-image-stats.mjs +11 -0
  11. package/client-lib/image-browser-view-methods/create-directory.mjs +23 -0
  12. package/client-lib/image-browser-view-methods/delete.mjs +100 -0
  13. package/client-lib/image-browser-view-methods/drag-and-drop.mjs +57 -0
  14. package/client-lib/image-browser-view-methods/file-obj-manipulation.mjs +190 -0
  15. package/client-lib/image-browser-view-methods/sink.mjs +20 -0
  16. package/client-lib/image-browser-view-methods/upload.mjs +159 -0
  17. package/client-lib/image-browser-view-methods/utils.mjs +108 -0
  18. package/client-lib/image-browser-view-methods/view-interactions.mjs +96 -0
  19. package/client-lib/image-browser-view.mjs +227 -0
  20. package/client-lib/image-resize.mjs +32 -0
  21. package/client-lib/info-dialog.mjs +36 -0
  22. package/client-lib/load-styles.mjs +14 -0
  23. package/client-lib/make-image-set.mjs +75 -0
  24. package/client-lib/name-parts.mjs +24 -0
  25. package/client-lib/styles-loaded.mjs +18 -0
  26. package/less/components.less +357 -0
  27. package/package.json +80 -0
  28. package/public/tree-file-browser/resources/css/tree-browser.css +394 -0
  29. package/public/tree-file-browser/resources/css/tree-browser.css.map +1 -0
  30. package/public/tree-file-browser/resources/js/tree-file-browser.js +2 -0
  31. package/public/tree-file-browser/resources/js/tree-file-browser.js.map +1 -0
  32. package/server-lib/initialize-tree-browser-resources.cjs +17 -0
  33. package/server-lib/initialize-tree-browser-resources.mjs +15 -0
  34. package/views/load-browser-views.js +13 -0
  35. package/views/webhandle-tree-image-browser/extension-pill.tri +1 -0
  36. package/views/webhandle-tree-image-browser/guilded-file-upload-form.tri +11 -0
  37. package/views/webhandle-tree-image-browser/guilded-image-upload-form.tri +33 -0
  38. package/views/webhandle-tree-image-browser/image-browser-frame.tri +122 -0
  39. package/views/webhandle-tree-image-browser/variant-choice-box.tri +25 -0
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # Webhandle / Tree Image Browser
2
+
3
+ Mostly client side html/css/js components to browse, select, upload, and delete files
4
+ on a server from a browser. It uses the [File Sink](https://www.npmjs.com/package/file-sink)
5
+ family of packages to provide access to server files.
6
+
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @webhandle/tree-file-browser
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ This package can be used either as something built into a single page app or as a piece of
17
+ code loaded on demand.
18
+
19
+ ### Single page app
20
+
21
+ Set up a file manager
22
+
23
+ ```js
24
+ import { ImageBrowserView, FileSelectDialog, loadStyles } from '@webhandle/tree-file-browser/client-lib/dynamic-load.mjs'
25
+
26
+ let treeHolder = document.querySelector('.webhandle-file-tree-image-browser')
27
+ if(treeHolder) {
28
+ loadStyles()
29
+ let imageBrowserView = new ImageBrowserView({
30
+ // The source of the files
31
+ sink: the file sink
32
+ // optional, an panel to post events
33
+ , eventNotificationPanel: eventPanel
34
+ // optional, the directory which to open to
35
+ , startingDirectory: 'img/empty'
36
+ })
37
+ imageBrowserView.appendTo(treeHolder)
38
+ imageBrowserView.render()
39
+ }
40
+
41
+ ```
42
+
43
+ `loadStyles` causes css to be added to the page which support the browser. Different setups are possible,
44
+ but by default, the styles expect the content to be a child of an element with a class `webhandle-file-tree-image-browser`.
45
+ Alternatively, the styles can be built into the pages stylesheet by including the file
46
+ `@webhandle/tree-file-browser/less/components.less`. `loadStyles` attempts to determine if the css is already in place,
47
+ so there's no harm in calling it if the css has already been included.
48
+
49
+ Loading the css dynamically or loading the file browser dynamically requires that the compiled resources have been added to
50
+ files available to the browser. These are at `@webhandle/tree-file-browser/resources` and must be available at the url
51
+ `/@webhandle/tree-file-browser/resources`
52
+
53
+ If you're using webhandle, you can set up all the server side resources, including kapla-tree-on-page and the material icons,
54
+ by using:
55
+
56
+ ```js
57
+ import webhandle from "webhandle"
58
+ import initializeTreeBrowserResources from "@webhandle/tree-file-browser/server-lib/initialize-tree-browser-resources.mjs"
59
+ initializeTreeBrowserResources(webhandle)
60
+ ```
61
+
62
+ or the equivalent common js include:
63
+
64
+ ```js
65
+ const webhandle = require('webhandle')
66
+ const initializeTreeBrowserResources = require("@webhandle/tree-file-browser/server-lib/initialize-tree-browser-resources.cjs")
67
+ initializeTreeBrowserResources(webhandle)
68
+ ```
69
+
70
+ This code can also be used as a file selection dialog like:
71
+
72
+ ```js
73
+ import { ImageBrowserView, FileSelectDialog, loadStyles } from '@webhandle/tree-file-browser/client-lib/dynamic-load.mjs'
74
+ let selectButton = document.querySelector('.select-image')
75
+ if(selectButton) {
76
+ selectButton.addEventListener('click', async function(evt) {
77
+ evt.preventDefault()
78
+ let diag = new FileSelectDialog({
79
+ sink: the-file-sink
80
+ , startingDirectory: 'img'
81
+ , imagesOnly: true
82
+ })
83
+ let result = await diag.open()
84
+ console.log(result)
85
+ })
86
+ }
87
+
88
+ ```
89
+
90
+
91
+ ### Dynmaically load
92
+
93
+ Using it independently on a single page is also pretty easy
94
+
95
+ ```html
96
+ <script type="module">
97
+ import { ImageBrowserView, loadStyles } from '/@webhandle/tree-file-browser/resources/js/tree-file-browser.js'
98
+ loadStyles()
99
+
100
+ let treeHolder = document.querySelector('.webhandle-file-tree-image-browser')
101
+ if(treeHolder) {
102
+ let imageBrowserView = new ImageBrowserView({
103
+ sink: the-file-sink
104
+ , startingDirectory: 'img'
105
+ })
106
+ imageBrowserView.appendTo(treeHolder)
107
+ imageBrowserView.render()
108
+
109
+ imageBrowserView.emitter.on('select', async function(evt) {
110
+ console.log(await imageBrowserView.getSelectedUrl())
111
+ })
112
+ }
113
+ </script>
114
+
115
+ ```
116
+
117
+ ### Selections
118
+
119
+ You can listen for selections like:
120
+
121
+ ```js
122
+ imageBrowserView.emitter.on('select', async function(evt) {
123
+ console.log(await imageBrowserView.getSelectedUrl())
124
+ })
125
+ ```
126
+
127
+ ### Options
128
+
129
+ ```js
130
+ /**
131
+ * Construct a new file browser
132
+ * @param {object} options
133
+ * @param {FileSink} options.sink The file to use as a file source
134
+ * @param {boolean} [options.imagesOnly] Set to true if you would like to display only images
135
+ * @param {boolean} [options.allowFileSelection] Set to true so that selected files are marked
136
+ * @param {EventNotificationPanel} [options.eventNotificationPanel] The panel which status messages will be added to.
137
+ * @param {string} [options.startingDirectory] Opens to that directory path if it exists
138
+ * @param {boolean} [options.deleteWithoutConfirm] False by default
139
+ * @param {boolean} [options.ignoreGlobalEvents] False by default, if true it will not listen to events like paste or keypresses
140
+ * which occur on the document
141
+ * @param {Emitter} [options.emitter] Emitter for various file events
142
+ */
143
+
144
+ ```
@@ -0,0 +1,41 @@
1
+
2
+ /**
3
+ *
4
+ * @param {File,string} file
5
+ */
6
+ export default function baseImageName(file) {
7
+ let name
8
+ if(typeof file === 'string') {
9
+ name = file
10
+ }
11
+ else if(file instanceof File) {
12
+ name = file.name
13
+ }
14
+
15
+ let parts = name.split('.')
16
+
17
+ if(parts.length > 1) {
18
+ parts.pop()
19
+ }
20
+ name = parts.join('.')
21
+ name = name.replace(/-@2x$/, '')
22
+ name = name.replace(/@2x$/, '')
23
+ name = name.replace(/-2x$/, '')
24
+ name = name.replace(/2x$/, '')
25
+
26
+ let chars = [name.substring(0, 1)]
27
+ for(let char of name.substring(1)) {
28
+ if(char.match(/[A-Z]/)) {
29
+ chars.push('-')
30
+ }
31
+ chars.push(char)
32
+ }
33
+ name = chars.join('')
34
+
35
+ name = name.toLowerCase()
36
+ name = name.replace(/[^1234567890a-z-]/g, '-')
37
+ name = name.replace(/--+/g, '-')
38
+
39
+
40
+ return name
41
+ }
@@ -0,0 +1,45 @@
1
+
2
+ /**
3
+ * Takes data, perhaps even an image, and resolves to a fully
4
+ * loaded image.
5
+ * @param {Blob,ArrayBuffer,string,Image} data
6
+ */
7
+ export default async function dataToImage(data) {
8
+ return new Promise((resolve, reject) => {
9
+ let finalImage
10
+ let objectUrl
11
+ if (data instanceof Image) {
12
+ finalImage = data
13
+ }
14
+ else if (typeof data === 'string') {
15
+ finalImage = new Image();
16
+ finalImage.src = data;
17
+ }
18
+ // ArrayBuffer.isView(data) might be true or could be a Blob or File
19
+ else {
20
+ // let's make a blob first. This works if it's an ArrayBuffer of a Blob
21
+ let imageBlob = new Blob([data])
22
+ objectUrl = URL.createObjectURL(imageBlob)
23
+
24
+ finalImage = new Image()
25
+ finalImage.src = objectUrl
26
+ }
27
+
28
+ function finish() {
29
+ if (objectUrl) {
30
+ URL.revokeObjectURL(objectUrl)
31
+ }
32
+ resolve(finalImage)
33
+ }
34
+ if (finalImage.complete) {
35
+ finish()
36
+ }
37
+ else {
38
+ // we'll have to wait till it's loaded
39
+ finalImage.addEventListener('load', () => {
40
+ finish()
41
+ })
42
+ }
43
+ })
44
+
45
+ }
@@ -0,0 +1,21 @@
1
+ import ibv from './image-browser-view.mjs';
2
+ import { FileSelectDialog as fsd } from './file-select-dialog.mjs'
3
+
4
+ import ls from './load-styles.mjs'
5
+
6
+
7
+
8
+ export let ImageBrowserView = ibv
9
+ export let FileSelectDialog = fsd
10
+ export let loadStyles = ls
11
+
12
+ if(typeof window !== 'undefined') {
13
+ window['@webhandle/tree-file-browser'] = {
14
+ ImageBrowserView
15
+ , FileSelectDialog
16
+ , loadStyles
17
+ }
18
+ }
19
+ export default ImageBrowserView
20
+
21
+
@@ -0,0 +1,70 @@
1
+ import Dialog from 'ei-dialog'
2
+ import ImageBrowserView from './image-browser-view.mjs'
3
+
4
+ export class FileSelectDialog extends Dialog {
5
+ constructor(options) {
6
+ super(Object.assign({
7
+ title: 'Select A File'
8
+ , body: `<div class="webhandle-file-tree-image-browser" style="width: 87vw;"> </div>`
9
+ , afterOpen: function (bodyElement, dialog) {
10
+
11
+ let treeHolder = bodyElement.querySelector('.webhandle-file-tree-image-browser')
12
+ if (treeHolder) {
13
+ let options = {
14
+ sink: dialog.sink
15
+ , imagesOnly: dialog.imagesOnly
16
+ , eventNotificationPanel: dialog.eventNotificationPanel
17
+ , startingDirectory: dialog.startingDirectory
18
+ , deleteWithoutConfirm: dialog.deleteWithoutConfirm
19
+ }
20
+
21
+ let imageBrowserView = this.imageBrowserView = new ImageBrowserView(options)
22
+ if(dialog._createAccessUrl) {
23
+ imageBrowserView._createAccessUrl = dialog._createAccessUrl
24
+ }
25
+ if(dialog._transformRelativeUrlToPublic) {
26
+ imageBrowserView._transformRelativeUrlToPublic = dialog._transformRelativeUrlToPublic
27
+ }
28
+ imageBrowserView.appendTo(treeHolder)
29
+ imageBrowserView.render()
30
+
31
+ imageBrowserView.emitter.on('select', async function (evt) {
32
+
33
+ })
34
+ }
35
+ }
36
+ }, options,
37
+ {
38
+ on: {
39
+ '.btn-ok': async () => {
40
+ let result = {
41
+ selection: this.imageBrowserView.getSelectedFiles()
42
+ }
43
+ result.url = await this.imageBrowserView.getSelectedUrl(result.selection)
44
+ this.resolve(result)
45
+
46
+ return true
47
+ },
48
+ '.mask': () => {
49
+ this.resolve()
50
+ return true
51
+ },
52
+ '.btn-cancel': () => {
53
+ this.resolve()
54
+ return true
55
+ }
56
+ }
57
+ }
58
+ ))
59
+ }
60
+
61
+ async open() {
62
+ this.promise = new Promise((resolve, reject) => {
63
+ this.resolve = resolve
64
+ this.reject = reject
65
+ })
66
+ super.open()
67
+
68
+ return this.promise
69
+ }
70
+ }
@@ -0,0 +1,65 @@
1
+ import Dialog from 'ei-dialog'
2
+ import formValueInjector from 'form-value-injector'
3
+ import gatherFormData from '@webhandle/gather-form-data'
4
+
5
+
6
+ export class FormAnswerDialog extends Dialog {
7
+ /**
8
+ *
9
+ * @param {Object} options Properties to create the dialog box. In addition to the properties from Dialog
10
+ * there those below.
11
+ * @param {Object} options.data The data which will be used to populate the controls in the dialog
12
+ */
13
+ constructor(options) {
14
+ super(Object.assign({}, options,
15
+ {
16
+ on: {
17
+ '.btn-ok': () => {
18
+ this.resolve(this.gatherData())
19
+ return true
20
+ },
21
+ '.mask': () => {
22
+ this.resolve()
23
+ return true
24
+ },
25
+ '.btn-cancel': () => {
26
+ this.resolve()
27
+ return true
28
+ }
29
+ }
30
+
31
+ }
32
+ ))
33
+ if (this.afterOpen) {
34
+ this.afterOpenOriginal = this.afterOpen
35
+ }
36
+ this.afterOpen = function (bodyElement, self) {
37
+ if (this.data) {
38
+ bodyElement.innerHTML = formValueInjector(bodyElement.innerHTML, this.data)
39
+ }
40
+ let firstInput = bodyElement.querySelector('input, textarea')
41
+ if (firstInput) {
42
+ firstInput.focus()
43
+ }
44
+
45
+ if (this.afterOpenOriginal) {
46
+ this.afterOpenOriginal(bodyElement, self)
47
+ }
48
+ }
49
+ }
50
+ gatherData() {
51
+ let dialogBody = document.querySelector(this.getBodySelector())
52
+ return gatherFormData(dialogBody)
53
+ }
54
+
55
+ async open() {
56
+ this.promise = new Promise((resolve, reject) => {
57
+ this.resolve = resolve
58
+ this.reject = reject
59
+ })
60
+ super.open()
61
+
62
+ return this.promise
63
+ }
64
+
65
+ }
@@ -0,0 +1,10 @@
1
+
2
+ export default function formatBytes(bytes, decimals) {
3
+ if (bytes == 0)
4
+ return '0 Bytes'
5
+ var k = 1024,
6
+ dm = decimals || 2,
7
+ sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
8
+ i = Math.floor(Math.log(bytes) / Math.log(k))
9
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
10
+ }
@@ -0,0 +1,17 @@
1
+
2
+ let types = {
3
+ "image/png": "png"
4
+ , "image/jpeg": "jpg"
5
+ , "image/jpg": "jpg"
6
+ , "image/webp": "webp"
7
+ }
8
+
9
+
10
+ export default function getExtension(mime) {
11
+ if (mime in types) {
12
+ return types[mime]
13
+ }
14
+
15
+ let ext = mime.split('/').pop()
16
+ return ext
17
+ }
@@ -0,0 +1,17 @@
1
+ import dataToImage from './data-to-image.mjs'
2
+ import getImageStats from './get-image-stats.mjs'
3
+ import getExtension from './get-extension-from-mime.mjs'
4
+
5
+ /**
6
+ *
7
+ * @param {File} file
8
+ * @returns
9
+ */
10
+ export default async function getFileImageStats(file) {
11
+ let source = await dataToImage(file)
12
+ let stats = await getImageStats(source)
13
+ stats.ratio = stats.width / stats.height
14
+ stats.ext = getExtension(file.type)
15
+
16
+ return stats
17
+ }
@@ -0,0 +1,11 @@
1
+ import dataToImage from "./data-to-image.mjs";
2
+
3
+ export default async function getImageStats(data) {
4
+ let img = await dataToImage(data)
5
+ return {
6
+ width: img.naturalWidth
7
+ , height: img.naturalHeight
8
+
9
+ }
10
+
11
+ }
@@ -0,0 +1,23 @@
1
+ import { FormAnswerDialog } from '../form-answer-dialog.mjs'
2
+
3
+ export function createDirectory(evt, selected) {
4
+ let dialog = new FormAnswerDialog({
5
+ title: 'Create Directory'
6
+ , body: '<label>Directory name <input type="text" name="name" /></label>'
7
+ })
8
+ let prom = dialog.open()
9
+ prom.then(async data => {
10
+ if (data) {
11
+ let directoryPath = this.currentNode.file.relPath + '/' + data.name
12
+ await this.sink.mkdir(directoryPath)
13
+ let file = await this.sink.getFullFileInfo(directoryPath)
14
+ let node = this._fileToKalpaNode(file)
15
+ this.tree.options.stream.emit('data', node)
16
+ let cur = this.tree.selected()
17
+ if (cur) {
18
+ this.tree.expand(cur.id)
19
+ }
20
+ }
21
+ })
22
+
23
+ }
@@ -0,0 +1,100 @@
1
+ import { FormAnswerDialog } from '../form-answer-dialog.mjs'
2
+
3
+ export async function deleteFile(evt, selected) {
4
+ let sel = this.getSelectedFiles()
5
+
6
+ if (sel.files.length > 0) {
7
+
8
+ let files = sel.files
9
+ let names = sel.names
10
+
11
+ if (!this.deleteWithoutConfirm) {
12
+ let dialog = new FormAnswerDialog({
13
+ title: 'Delete File' + (files.length > 1 ? 's' : '')
14
+ , body: '<p>' + names.join(', ') + '</p>'
15
+ })
16
+ let prom = dialog.open()
17
+ let ans = await prom
18
+ if (!ans) {
19
+ return
20
+ }
21
+ }
22
+
23
+ for (let file of files) {
24
+ let path = file.relPath
25
+ let note
26
+ if (this.eventNotificationPanel) {
27
+ note = this.eventNotificationPanel.addNotification({
28
+ model: {
29
+ status: 'pending',
30
+ headline: `deleting ${file.name}`
31
+ }
32
+ })
33
+ }
34
+ await this.sink.rm(path)
35
+ if (this.eventNotificationPanel) {
36
+ note.remove()
37
+ note = this.eventNotificationPanel.addNotification({
38
+ model: {
39
+ status: 'success',
40
+ headline: `removed ${file.name}`
41
+ }
42
+ , ttl: 2000
43
+ })
44
+ }
45
+ }
46
+ for (let item of sel.boxes) {
47
+ item.remove()
48
+ }
49
+ }
50
+ this.emitter.emit('delete', {
51
+ type: 'delete'
52
+ , selected: sel
53
+ })
54
+ }
55
+
56
+ export async function deleteDirectory(evt, selected) {
57
+ let path = this.currentNode.file.relPath
58
+ let name = this.currentNode.file.name
59
+
60
+ if (!path) {
61
+ // probably the root, just cancel
62
+ return
63
+ }
64
+
65
+ let dialog = new FormAnswerDialog({
66
+ title: 'Delete Directory'
67
+ , body: '<p>' + name + '</p>'
68
+ })
69
+ let prom = dialog.open()
70
+ let ans = await prom
71
+ if (!ans) {
72
+ return
73
+ }
74
+ let note
75
+ if (this.eventNotificationPanel) {
76
+ note = this.eventNotificationPanel.addNotification({
77
+ model: {
78
+ status: 'pending',
79
+ headline: `deleting ${name}`
80
+ }
81
+ })
82
+ }
83
+ await this.sink.rm(path, { recursive: true })
84
+ let curSelected = this.tree.selected()
85
+ let parent = this.tree.parent(curSelected)
86
+
87
+ this.tree.removeNode(curSelected)
88
+ this.tree.select(parent.id)
89
+
90
+ if (this.eventNotificationPanel) {
91
+ note.remove()
92
+ note = this.eventNotificationPanel.addNotification({
93
+ model: {
94
+ status: 'success',
95
+ headline: `removed ${name}`
96
+ }
97
+ , ttl: 2000
98
+ })
99
+ }
100
+ }
@@ -0,0 +1,57 @@
1
+
2
+ export function getDropCoverSelector() {
3
+ return '.img-drop-cover'
4
+ }
5
+
6
+ export async function handleDrop(evt, selected) {
7
+ let uploadType = 'literal'
8
+ let dropSquare = evt.target.closest('.drop-type')
9
+ if (dropSquare) {
10
+ if (dropSquare.classList.contains('guided-upload')) {
11
+ uploadType = 'guided'
12
+ }
13
+ else if (dropSquare.classList.contains('automatic')) {
14
+ uploadType = 'automatic'
15
+ }
16
+ }
17
+
18
+ this._cleanupDropDone()
19
+ evt.preventDefault()
20
+ let files = await this._getFilesFromEvent(evt)
21
+ this.uploadFiles(files, { uploadType })
22
+ }
23
+
24
+ export function isFileTypeDrag(evt) {
25
+ let fileType = true
26
+ if (evt.dataTransfer) {
27
+ if (evt.dataTransfer.items[0].kind === 'string') {
28
+ fileType = false
29
+ }
30
+ }
31
+
32
+ return fileType
33
+ }
34
+
35
+ export function dragEnter(evt, selected) {
36
+ let overlay = this.isFileTypeDrag(evt)
37
+ if (overlay) {
38
+ this.overCount++
39
+ this.el.querySelector(this.getDropCoverSelector()).classList.add('file-dropping')
40
+ }
41
+ }
42
+ export function dragLeave(evt, selected) {
43
+ if (this.isFileTypeDrag(evt)) {
44
+ this.overCount--
45
+ if (this.overCount == 0) {
46
+ this._cleanupDropDone()
47
+ }
48
+ }
49
+ }
50
+ export function dragOver(evt, selected) {
51
+ evt.preventDefault()
52
+ }
53
+
54
+ export function _cleanupDropDone() {
55
+ this.overCount = 0;
56
+ [...this.el.querySelectorAll('.file-dropping')].forEach(cover => cover.classList.remove('file-dropping'))
57
+ }