fwtoolkit 0.1.0-alpha.1 → 0.1.0-alpha.3

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.
@@ -0,0 +1,9 @@
1
+ export {FileDialog} from "./dialog.js"
2
+ export {FileSelector} from "./selector.js"
3
+ export {
4
+ cleanPath,
5
+ moveFile,
6
+ shortFileTitle,
7
+ longFilePath
8
+ } from "./tools.js"
9
+ export {NewFolderDialog} from "./new_folder_dialog.js"
@@ -0,0 +1,37 @@
1
+ import {Dialog} from "../dialog.js"
2
+ import {newFolderTemplate} from "./templates.js"
3
+
4
+ export class NewFolderDialog {
5
+ constructor(callback = _foldername => {}) {
6
+ this.callback = callback
7
+ this.dialog = new Dialog({
8
+ title: gettext("New folder"),
9
+ id: "new-folder",
10
+ width: 400,
11
+ height: 150,
12
+ body: newFolderTemplate(),
13
+ buttons: [
14
+ {type: "cancel"},
15
+ {
16
+ text: gettext("Create folder"),
17
+ classes: "fw-dark",
18
+ click: () => {
19
+ const folderName =
20
+ this.dialog.dialogEl.querySelector(
21
+ "#new-folder-name"
22
+ ).value
23
+ this.dialog.close()
24
+ if (!folderName.length) {
25
+ return
26
+ }
27
+ this.callback(folderName)
28
+ }
29
+ }
30
+ ]
31
+ })
32
+ }
33
+
34
+ open() {
35
+ return this.dialog.open()
36
+ }
37
+ }
@@ -0,0 +1,263 @@
1
+ import {escapeText, findTarget} from "../basic.js"
2
+ import {ensureCSS} from "../network.js"
3
+
4
+ export class FileSelector {
5
+ constructor({
6
+ dom,
7
+ files,
8
+ showFiles = true,
9
+ selectFolders = true,
10
+ multiSelect = false,
11
+ selectDir = _path => {},
12
+ selectFile = _path => {},
13
+ fileIcon = "far fa-file-alt"
14
+ }) {
15
+ this.dom = dom
16
+ this.files = files
17
+ this.showFiles = showFiles // Whether to show existing files or only folders
18
+ this.selectFolders = selectFolders // Whether to allow the selection of folders
19
+ this.multiSelect = multiSelect // Whether to allow the selectioj of multiple entries
20
+ this.selectDir = selectDir
21
+ this.selectFile = selectFile
22
+ this.fileIcon = fileIcon // File icon to use
23
+ this.root = {
24
+ name: "/",
25
+ type: "folder",
26
+ open: true,
27
+ selected: false,
28
+ path: "/",
29
+ children: []
30
+ }
31
+ this.selected = []
32
+ if (this.selectFolders && !this.multiSelect) {
33
+ this.root.selected = true
34
+ this.selected.push(this.root)
35
+ }
36
+ }
37
+
38
+ init() {
39
+ this.readDirStructure()
40
+ this.sortDirStructure()
41
+ ensureCSS(staticUrl("css/file_selector.css"))
42
+ this.dom.classList.add("fw-file-selector")
43
+ this.render()
44
+ this.bind()
45
+ }
46
+
47
+ readDirStructure() {
48
+ // Read directory structure from existing file paths.
49
+ // A file's title is used as the final path segment when the file has
50
+ // no explicit folder path. We strip any "/" from that segment because
51
+ // some titles (e.g. E2EE ciphertext encoded in standard base64) contain
52
+ // "/" characters which would otherwise be mis-interpreted as path
53
+ // separators, nesting the file inside phantom collapsed folders.
54
+ this.files.forEach(file => {
55
+ let treeWalker = this.root.children
56
+ let path = file.path
57
+ if (!path.length || path.endsWith("/")) {
58
+ const safeName = (file.title || gettext("Untitled")).replace(
59
+ /\//g,
60
+ "\u2215" // U+2215 DIVISION SLASH — visually identical, not a path separator
61
+ )
62
+ path += safeName
63
+ }
64
+ const pathParts = path.split("/")
65
+ pathParts.forEach((pathPart, pathIndex) => {
66
+ if (!pathPart.length) {
67
+ return
68
+ }
69
+ if (pathIndex === pathParts.length - 1) {
70
+ if (this.showFiles) {
71
+ treeWalker.push({
72
+ name: pathPart,
73
+ type: "file",
74
+ path: pathParts.slice(0, pathIndex + 1).join("/"),
75
+ file
76
+ })
77
+ }
78
+ return
79
+ }
80
+ let folder = treeWalker.find(
81
+ item => item.name === pathPart && item.type === "folder"
82
+ )
83
+ if (!folder) {
84
+ folder = {
85
+ name: pathPart,
86
+ type: "folder",
87
+ open: false,
88
+ selected: false,
89
+ path: pathParts.slice(0, pathIndex + 1).join("/") + "/",
90
+ children: []
91
+ }
92
+ treeWalker.push(folder)
93
+ }
94
+ treeWalker = folder.children
95
+ })
96
+ })
97
+ }
98
+
99
+ sortDirStructure(entries = this.root.children) {
100
+ entries.sort((a, b) => {
101
+ if (a.type !== b.type) {
102
+ return a.type === "folder" ? -1 : 1
103
+ }
104
+ return a.name > b.name ? 1 : -1
105
+ })
106
+ entries.forEach(entry => {
107
+ if (entry.type === "folder" && entry.children.length) {
108
+ this.sortDirStructure(entry.children)
109
+ }
110
+ })
111
+ }
112
+
113
+ addFolder(rawName) {
114
+ const name = rawName.replace(/\//g, "")
115
+ // Add a new folder as a subfolder to the currently selected folder
116
+ if (
117
+ !this.selected.length ||
118
+ this.selected[0].type !== "folder" ||
119
+ this.selected[0].children.find(
120
+ child => child.type === "folder" && child.name === name
121
+ )
122
+ ) {
123
+ // A file is selected. Give up.
124
+ return
125
+ }
126
+ const newFolder = {
127
+ name,
128
+ type: "folder",
129
+ open: true,
130
+ selected: true,
131
+ path: this.selected[0].path + name + "/",
132
+ children: []
133
+ }
134
+ this.selected[0].children.push(newFolder)
135
+ this.sortDirStructure(this.selected[0].children)
136
+ this.selected[0].open = true
137
+ if (!this.multiSelect) {
138
+ this.selected[0].selected = false
139
+ this.selected = []
140
+ }
141
+ this.selected.push(newFolder)
142
+ this.selectDir(newFolder.path)
143
+ this.render()
144
+ }
145
+
146
+ deselectAll() {
147
+ this.selected.forEach(entry => (entry.selected = false))
148
+ this.selected = []
149
+ this.render()
150
+ }
151
+
152
+ render() {
153
+ this.dom.innerHTML = this.renderFolder(this.root)
154
+ }
155
+
156
+ renderFolder(folder, indentLevel = 0) {
157
+ let returnString = ""
158
+ returnString += `<div class="folder${folder.open ? "" : " closed"}">`
159
+ returnString += `<p style="margin-left:${indentLevel * 10}px;">${
160
+ folder.children.length
161
+ ? `<i class="far fa-${folder.open ? "minus" : "plus"}-square"></i>&nbsp;`
162
+ : ""
163
+ }<span class="folder-name${folder.selected ? " selected" : ""}"><i class="fas fa-folder"></i>&nbsp;${escapeText(folder.name)}</span></p>`
164
+ if (folder.open) {
165
+ returnString += '<div class="folder-content">'
166
+ returnString += folder.children
167
+ .map(child => {
168
+ if (child.type === "folder") {
169
+ return this.renderFolder(child, indentLevel + 1)
170
+ } else {
171
+ return `<p class="file" style="margin-left:${(indentLevel + 1) * 10 + 20}px;"><span class="file-name${child.selected ? " selected" : ""}"><i class="${this.fileIcon}"></i>&nbsp;${escapeText(child.name)}</span></p>`
172
+ }
173
+ })
174
+ .join("")
175
+ returnString += "</div>"
176
+ }
177
+ returnString += "</div>"
178
+ return returnString
179
+ }
180
+
181
+ findEntry(dom) {
182
+ const searchPath = []
183
+ let seekItem = dom
184
+ while (seekItem.closest("div.folder, p.file")) {
185
+ let itemNumber = 0
186
+ seekItem = seekItem.closest("div.folder, p.file")
187
+ while (seekItem.previousElementSibling) {
188
+ itemNumber++
189
+ seekItem = seekItem.previousElementSibling
190
+ }
191
+ searchPath.push(itemNumber)
192
+ seekItem = seekItem.parentElement
193
+ }
194
+ let entry = this.root
195
+ searchPath.pop()
196
+ while (searchPath.length) {
197
+ entry = entry.children[searchPath.pop()]
198
+ }
199
+ return entry
200
+ }
201
+
202
+ bind() {
203
+ this.dom.addEventListener("click", event => {
204
+ const el = {}
205
+ switch (true) {
206
+ case findTarget(event, ".fa-plus-square", el): {
207
+ event.preventDefault()
208
+ const entry = this.findEntry(el.target)
209
+ entry.open = true
210
+ this.render()
211
+ break
212
+ }
213
+ case findTarget(event, ".fa-minus-square", el): {
214
+ event.preventDefault()
215
+ const entry = this.findEntry(el.target)
216
+ entry.open = false
217
+ this.render()
218
+ break
219
+ }
220
+ case findTarget(event, ".folder-name", el): {
221
+ event.preventDefault()
222
+ if (!this.selectFolders) {
223
+ // Folders cannot be selected
224
+ return
225
+ }
226
+ const entry = this.findEntry(el.target)
227
+ if (this.selected.includes(entry)) {
228
+ entry.selected = false
229
+ this.selected = this.selected.filter(e => e !== entry)
230
+ this.render()
231
+ } else {
232
+ entry.selected = true
233
+ if (!this.multiSelect && this.selected.length) {
234
+ this.selected[0].selected = false
235
+ }
236
+ this.selected.push(entry)
237
+ this.render()
238
+ this.selectDir(entry.path)
239
+ }
240
+ break
241
+ }
242
+ case findTarget(event, ".file-name", el): {
243
+ event.preventDefault()
244
+ const entry = this.findEntry(el.target)
245
+ if (this.selected.includes(entry)) {
246
+ entry.selected = false
247
+ this.selected = this.selected.filter(e => e !== entry)
248
+ this.render()
249
+ } else {
250
+ entry.selected = true
251
+ if (!this.multiSelect && this.selected.length) {
252
+ this.selected[0].selected = false
253
+ }
254
+ this.selected.push(entry)
255
+ this.render()
256
+ this.selectFile(entry.path)
257
+ }
258
+ break
259
+ }
260
+ }
261
+ })
262
+ }
263
+ }
@@ -0,0 +1,11 @@
1
+ import {escapeText} from "../index.js"
2
+
3
+ export const moveTemplate = ({path}) =>
4
+ `<div>
5
+ <span>${gettext("Path")}:</span>
6
+ <input type="text" value="${escapeText(path)}" id="path" placeholder="${gettext("Insert path")}">
7
+ <div class="file-selector"></div>
8
+ </div>`
9
+
10
+ export const newFolderTemplate = () =>
11
+ `<div><input type="text" id="new-folder-name" placeholder="${gettext("Insert folder name")}"></div>`
@@ -0,0 +1,58 @@
1
+ import {escapeText} from "../basic.js"
2
+ import {postJson} from "../network.js"
3
+
4
+ export const shortFileTitle = (title, path) => {
5
+ if (!path.length || path.endsWith("/")) {
6
+ return escapeText(title || gettext("Untitled"))
7
+ }
8
+ return escapeText(path.split("/").pop())
9
+ }
10
+
11
+ export const longFilePath = (title, path, prefix = "") => {
12
+ if (!path.length) {
13
+ path = "/"
14
+ }
15
+ if (path.endsWith("/")) {
16
+ path += title.replace(/\//g, "") || gettext("Untitled")
17
+ }
18
+ if (prefix.length) {
19
+ const pathParts = path.split("/")
20
+ const fileName = pathParts.pop()
21
+ pathParts.push(prefix + fileName)
22
+ path = pathParts.join("/")
23
+ }
24
+
25
+ return path
26
+ }
27
+
28
+ export const cleanPath = (title, path) => {
29
+ if (!path.startsWith("/")) {
30
+ path = "/" + path
31
+ }
32
+ path = path.replace(/\/{2,}/g, "/") // replace multiple backslashes
33
+
34
+ if (
35
+ path.endsWith(
36
+ `/${title.replace(/\//g, "")}` || `/${gettext("Untitled")}`
37
+ )
38
+ ) {
39
+ path = path.split("/").slice(0, -1).join("/") + "/"
40
+ }
41
+ if (path === "/") {
42
+ path = ""
43
+ }
44
+ return path
45
+ }
46
+
47
+ export const moveFile = (fileId, title, path, moveUrl) => {
48
+ path = cleanPath(title, path)
49
+ return new Promise((resolve, reject) => {
50
+ postJson(moveUrl, {id: fileId, path}).then(({json}) => {
51
+ if (json.done) {
52
+ resolve(path)
53
+ } else {
54
+ reject()
55
+ }
56
+ })
57
+ })
58
+ }
package/src/focus.js ADDED
@@ -0,0 +1,20 @@
1
+ // Get the index number of currently focused selement. This it to set tyhe focus close by after doing some dom changes.
2
+
3
+ export const getFocusIndex = () => {
4
+ return Array.from(
5
+ document.querySelectorAll(
6
+ "a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex], [contenteditable]"
7
+ )
8
+ ).findIndex(el => el === document.activeElement)
9
+ }
10
+
11
+ export const setFocusIndex = index => {
12
+ const focusableElements = Array.from(
13
+ document.querySelectorAll(
14
+ "a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex], [contenteditable]"
15
+ )
16
+ )
17
+ if (index >= 0 && index < focusableElements.length) {
18
+ focusableElements[index].focus()
19
+ }
20
+ }
package/src/index.js CHANGED
@@ -1,12 +1,61 @@
1
- export {escapeText, unescapeText, noSpaceTmp} from "./text.js"
1
+ export {OverviewMenuView} from "./overview_menu.js"
2
+ export {
3
+ dropdownSelect,
4
+ setCheckableLabel,
5
+ activateWait,
6
+ deactivateWait,
7
+ addAlert,
8
+ langName,
9
+ localizeDate,
10
+ enableDatePicker,
11
+ noSpaceTmp,
12
+ escapeText,
13
+ unescapeText,
14
+ infoTooltip,
15
+ cancelPromise,
16
+ findTarget,
17
+ whenReady,
18
+ setDocTitle,
19
+ showSystemMessage
20
+ } from "./basic.js"
21
+
2
22
  export {convertDataURIToBlob} from "./blob.js"
3
- export {shortFileTitle, longFilePath} from "./file.js"
4
- export {get, post, postJson} from "./network.js"
5
23
 
6
- export const addAlert = (type, message) => {
7
- if (typeof console !== "undefined") {
8
- console.log(`[@fiduswriter/document alert][${type}] ${message}`)
9
- }
10
- }
24
+ export {isActivationEvent} from "./events.js"
25
+
26
+ export {getFocusIndex, setFocusIndex} from "./focus.js"
11
27
 
12
- export const deactivateWait = () => {}
28
+ export {
29
+ get,
30
+ getJson,
31
+ post,
32
+ postJson,
33
+ postBare,
34
+ ensureCSS,
35
+ getCookie
36
+ } from "./network.js"
37
+ export {
38
+ setLanguage,
39
+ avatarTemplate
40
+ } from "./user.js"
41
+ export {Dialog} from "./dialog.js"
42
+ export {ContentMenu} from "./content_menu.js"
43
+ export {makeWorker} from "./worker.js"
44
+ export {baseBodyTemplate} from "./templates.js"
45
+ export {WebSocketConnector} from "./ws.js"
46
+ export {filterPrimaryEmail} from "./user_util.js"
47
+ export {DatatableBulk} from "./datatable_bulk.js"
48
+ export {faqDialog} from "./faq_dialog.js"
49
+ export {
50
+ FileDialog,
51
+ FileSelector,
52
+ cleanPath,
53
+ moveFile,
54
+ shortFileTitle,
55
+ longFilePath,
56
+ NewFolderDialog
57
+ } from "./file/index.js"
58
+ export {
59
+ initSettings,
60
+ getSettings
61
+ } from "./settings.js"
package/src/network.js CHANGED
@@ -1,14 +1,29 @@
1
- const getCookie = name => {
2
- if (typeof document === "undefined" || !document.cookie) {
3
- return ""
1
+ import {getSettings} from "./settings.js"
2
+
3
+ /** Get cookie to set as part of the request header of all AJAX requests to the server.
4
+ * @param name The name of the token to look for in the cookie.
5
+ */
6
+ export const getCookie = name => {
7
+ if (!document.cookie || document.cookie === "") {
8
+ return null
4
9
  }
5
10
  const cookie = document.cookie
6
11
  .split(";")
7
12
  .map(cookie => cookie.trim())
8
- .find(cookie => cookie.substring(0, name.length + 1) === `${name}=`)
9
- return cookie ? decodeURIComponent(cookie.substring(name.length + 1)) : ""
13
+ .find(cookie => {
14
+ if (cookie.substring(0, name.length + 1) == name + "=") {
15
+ return true
16
+ } else {
17
+ return false
18
+ }
19
+ })
20
+ if (cookie) {
21
+ return decodeURIComponent(cookie.substring(name.length + 1))
22
+ }
23
+ return null
10
24
  }
11
25
 
26
+ /* from https://www.tjvantoll.com/2015/09/13/fetch-and-errors/ */
12
27
  const handleFetchErrors = response => {
13
28
  if (!response.ok) {
14
29
  throw response
@@ -16,8 +31,11 @@ const handleFetchErrors = response => {
16
31
  return response
17
32
  }
18
33
 
19
- export const get = (url, params = {}) => {
20
- const csrfToken = getCookie("csrftoken")
34
+ export const get = (url, params = {}, csrfToken = false) => {
35
+ const settings = getSettings()
36
+ if (!csrfToken) {
37
+ csrfToken = settings.getCsrfToken() // Won't work in web worker.
38
+ }
21
39
  const queryString = Object.keys(params)
22
40
  .map(
23
41
  key =>
@@ -27,7 +45,7 @@ export const get = (url, params = {}) => {
27
45
  if (queryString.length) {
28
46
  url = `${url}?${queryString}`
29
47
  }
30
- return fetch(url, {
48
+ return fetch(settings.apiUrl(url), {
31
49
  method: "GET",
32
50
  headers: {
33
51
  "X-CSRFToken": csrfToken,
@@ -38,8 +56,15 @@ export const get = (url, params = {}) => {
38
56
  }).then(handleFetchErrors)
39
57
  }
40
58
 
41
- export const post = (url, object = {}, files = {}) => {
42
- const csrfToken = getCookie("csrftoken")
59
+ export const getJson = (url, params = {}, csrfToken = false) =>
60
+ get(url, params, csrfToken).then(response => response.json())
61
+
62
+ export const postBare = (url, object = {}, files = {}, options = {}) => {
63
+ const settings = getSettings()
64
+
65
+ const {csrfToken: csrfTokenOpt, keepalive = false} = options
66
+ const csrfToken = csrfTokenOpt || settings.getCsrfToken() // Won't work in web worker.
67
+
43
68
  const fetchOptions = {
44
69
  method: "POST",
45
70
  headers: {
@@ -47,7 +72,8 @@ export const post = (url, object = {}, files = {}) => {
47
72
  Accept: "application/json",
48
73
  "X-Requested-With": "XMLHttpRequest"
49
74
  },
50
- credentials: "include"
75
+ credentials: "include",
76
+ keepalive
51
77
  }
52
78
 
53
79
  if (Object.keys(files).length) {
@@ -70,10 +96,33 @@ export const post = (url, object = {}, files = {}) => {
70
96
  fetchOptions.body = JSON.stringify(object)
71
97
  }
72
98
 
73
- return fetch(url, fetchOptions).then(handleFetchErrors)
99
+ return fetch(settings.apiUrl(url), fetchOptions)
100
+ }
101
+
102
+ export const post = (url, object = {}, files = {}, options = {}) => {
103
+ return postBare(url, object, files, options).then(handleFetchErrors)
74
104
  }
75
105
 
76
- export const postJson = (url, object = {}, files = {}) =>
77
- post(url, object, files).then(response =>
106
+ // post json object and return json and status
107
+ export const postJson = (url, object = {}, files = {}, options = {}) =>
108
+ post(url, object, files, options).then(response =>
78
109
  response.json().then(json => ({json, status: response.status}))
79
110
  )
111
+
112
+ export const ensureCSS = cssUrl => {
113
+ if (typeof cssUrl === "object") {
114
+ cssUrl.forEach(url => ensureCSS(url))
115
+ return
116
+ }
117
+ const link = document.createElement("link")
118
+ link.rel = "stylesheet"
119
+ link.href = cssUrl
120
+ const styleSheet = Array.from(document.styleSheets).find(
121
+ styleSheet => styleSheet.href === link.href
122
+ )
123
+ if (!styleSheet) {
124
+ document.head.appendChild(link)
125
+ return true
126
+ }
127
+ return false
128
+ }