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