tangled
alpha
login
or
join now
boltless.me
/
tangled-md-editor
0
fork
atom
markdown editor web component for tangled
0
fork
atom
overview
issues
pulls
pipelines
initial commit
Signed-off-by: Seongmin Lee <git@boltless.me>
boltless.me
3 months ago
eca7ee66
+229
3 changed files
expand all
collapse all
unified
split
index.html
jsconfig.json
tangled-md-editor.js
+35
index.html
···
1
1
+
<!doctype html>
2
2
+
<html lang="en">
3
3
+
<head>
4
4
+
<script type="module" src="./tangled-md-editor.js"></script>
5
5
+
<style>
6
6
+
/* dark mode for my eyes */
7
7
+
body, textarea {
8
8
+
color: #ddd;
9
9
+
background-color: #222;
10
10
+
}
11
11
+
tangled-md-editor textarea {
12
12
+
padding: 1em;
13
13
+
}
14
14
+
tangled-md-editor.drag-hover textarea {
15
15
+
outline: dashed 2px #ccc;
16
16
+
outline-offset: -0.5em;
17
17
+
}
18
18
+
tangled-md-editor:not(.drag-hover) button > .condensed,
19
19
+
tangled-md-editor.drag-hover button > .spacious {
20
20
+
display: none;
21
21
+
}
22
22
+
</style>
23
23
+
</head>
24
24
+
<body>
25
25
+
<div style="width: min-content">
26
26
+
<tangled-md-editor>
27
27
+
<textarea rows="20" cols="80"></textarea>
28
28
+
<button onclick="this.parentElement.insertFile()">
29
29
+
<span class="condensed">Add Files</span>
30
30
+
<span class="spacious">Paste, drop, or click to add files</span>
31
31
+
</button>
32
32
+
</tangled-md-editor>
33
33
+
</div>
34
34
+
</body>
35
35
+
</html>
+14
jsconfig.json
···
1
1
+
{
2
2
+
"compilerOptions": {
3
3
+
"target": "esnext",
4
4
+
"module": "esnext",
5
5
+
"checkJs": true,
6
6
+
"strict": true,
7
7
+
"verbatimModuleSyntax": true,
8
8
+
"isolatedModules": true,
9
9
+
"noUncheckedSideEffectImports": true,
10
10
+
"moduleDetection": "force",
11
11
+
"skipLibCheck": true
12
12
+
},
13
13
+
"include": ["./*.js"]
14
14
+
}
+180
tangled-md-editor.js
···
1
1
+
const template = document.createElement("template");
2
2
+
template.innerHTML = `
3
3
+
<slot></slot>
4
4
+
5
5
+
<style>
6
6
+
* {
7
7
+
margin: 0;
8
8
+
padding: 0;
9
9
+
box-sizing: border-box;
10
10
+
}
11
11
+
:host {
12
12
+
display: block;
13
13
+
position: relative;
14
14
+
}
15
15
+
</style>
16
16
+
`;
17
17
+
18
18
+
/**
19
19
+
* @template {HTMLElement} T
20
20
+
* @param {T} tmpl
21
21
+
*/
22
22
+
function clone(tmpl) {
23
23
+
return /** @type {T} */ (tmpl.cloneNode(true));
24
24
+
}
25
25
+
26
26
+
export default class TangledMarkdownEditor extends HTMLElement {
27
27
+
static tag = "tangled-md-editor";
28
28
+
29
29
+
static define(tag = this.tag) {
30
30
+
this.tag = tag;
31
31
+
32
32
+
const name = customElements.getName(this);
33
33
+
if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`);
34
34
+
35
35
+
const ce = customElements.get(tag);
36
36
+
if (ce && ce !== this) return console.warn(`${tag} already defined as ${ce.name}!`);
37
37
+
38
38
+
customElements.define(tag, this);
39
39
+
}
40
40
+
41
41
+
static {
42
42
+
const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag;
43
43
+
if (tag != "none") this.define(tag);
44
44
+
}
45
45
+
46
46
+
#shadow = this.attachShadow({ mode: "closed" });
47
47
+
#dragHoverClass = "drag-hover";
48
48
+
49
49
+
constructor() {
50
50
+
super();
51
51
+
52
52
+
this.#shadow.append(clone(template).content);
53
53
+
this.addEventListener("paste", (ev) => this.#onPaste(ev));
54
54
+
this.addEventListener("dragover", (ev) => this.#onDragOver(ev));
55
55
+
this.addEventListener("dragleave", (ev) => this.#onDragLeave(ev));
56
56
+
this.addEventListener("drop", (ev) => this.#onDrop(ev));
57
57
+
}
58
58
+
59
59
+
async insertFile() {
60
60
+
const input = document.createElement("input");
61
61
+
input.type = "file";
62
62
+
input.accept = "image/*";
63
63
+
input.multiple = true;
64
64
+
input.style.display = "none";
65
65
+
input.addEventListener("change", () => {
66
66
+
if (!input.files) return;
67
67
+
for (const file of input.files) {
68
68
+
this.#handleFile(file);
69
69
+
}
70
70
+
});
71
71
+
this.appendChild(input);
72
72
+
input.click();
73
73
+
this.removeChild(input);
74
74
+
}
75
75
+
76
76
+
/** @param {ClipboardEvent} ev */
77
77
+
async #onPaste(ev) {
78
78
+
const dt = ev.clipboardData;
79
79
+
if (!dt || !dt.files || dt.files.length === 0) return;
80
80
+
81
81
+
ev.preventDefault();
82
82
+
83
83
+
for (const file of dt.files) {
84
84
+
if (!file.type.startsWith("image/")) continue;
85
85
+
86
86
+
await this.#handleFile(file);
87
87
+
}
88
88
+
}
89
89
+
90
90
+
/** @param {DragEvent} ev */
91
91
+
async #onDragOver(ev) {
92
92
+
ev.preventDefault();
93
93
+
this.classList.add(this.#dragHoverClass);
94
94
+
}
95
95
+
96
96
+
/** @param {DragEvent} ev */
97
97
+
async #onDragLeave(ev) {
98
98
+
ev.preventDefault();
99
99
+
this.classList.remove(this.#dragHoverClass);
100
100
+
}
101
101
+
102
102
+
/** @param {DragEvent} ev */
103
103
+
async #onDrop(ev) {
104
104
+
this.classList.remove(this.#dragHoverClass);
105
105
+
106
106
+
const dt = ev.dataTransfer;
107
107
+
if (!dt || !dt.files || dt.files.length === 0) return;
108
108
+
109
109
+
ev.preventDefault();
110
110
+
111
111
+
for (const file of dt.files) {
112
112
+
if (!file.type.startsWith("image/")) continue;
113
113
+
114
114
+
await this.#handleFile(file);
115
115
+
}
116
116
+
}
117
117
+
118
118
+
/** @param {File} file */
119
119
+
async #handleFile(file) {
120
120
+
const textarea = this.querySelector("textarea");
121
121
+
if (!textarea) return;
122
122
+
123
123
+
const placeholder = `<!-- Uploading "${file.name}"... -->`;
124
124
+
125
125
+
this.#insertTextAtCursor(placeholder);
126
126
+
127
127
+
const url = await this.#upload(file);
128
128
+
129
129
+
const finalTag = ``;
130
130
+
131
131
+
textarea.value = textarea.value.replace(placeholder, finalTag);
132
132
+
}
133
133
+
134
134
+
/** @param {string} text */
135
135
+
#insertTextAtCursor(text) {
136
136
+
const textarea = this.querySelector("textarea");
137
137
+
if (!textarea) return;
138
138
+
const start = textarea.selectionStart;
139
139
+
const end = textarea.selectionEnd;
140
140
+
141
141
+
const before = textarea.value.slice(0, start);
142
142
+
const after = textarea.value.slice(end);
143
143
+
144
144
+
// add surrounding newlines if it's mid-line
145
145
+
if (before && !before.endsWith("\n")) text = "\n\n" + text;
146
146
+
if (after && !after.startsWith("\n")) text = text + "\n\n";
147
147
+
148
148
+
textarea.value = before + text + after;
149
149
+
150
150
+
const newPos = start + text.length;
151
151
+
textarea.selectionStart = textarea.selectionEnd = newPos;
152
152
+
153
153
+
textarea.dispatchEvent(
154
154
+
new InputEvent("input", { bubbles: true, inputType: "insertText", data: text })
155
155
+
);
156
156
+
// textarea.dispatchEvent(new Event("input", { bubbles: true }));
157
157
+
textarea.dispatchEvent(new Event("change", { bubbles: true }));
158
158
+
}
159
159
+
160
160
+
/** @param {File} file */
161
161
+
async #upload(file) {
162
162
+
await new Promise(r => setTimeout(r, 500));
163
163
+
164
164
+
const host = this.getAttribute("host") ?? "";
165
165
+
try {
166
166
+
const res = await fetch(host + "/xrpc/com.atproto.repo.uploadBlob", {
167
167
+
method: "POST",
168
168
+
body: file,
169
169
+
headers: {
170
170
+
"Content-Type": file.type,
171
171
+
},
172
172
+
});
173
173
+
const output = await res.json();
174
174
+
return output.blob.ref["$link"]
175
175
+
} catch (e) {
176
176
+
console.error("failed to upload blob", e)
177
177
+
return "failed"
178
178
+
}
179
179
+
}
180
180
+
}