ordars_rails_editor 0.1.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.
@@ -0,0 +1,420 @@
1
+ // ordars-rails-editor 셸 — 편집 코어는 Tiptap(번들 내장), 이 파일은 툴바·팔레트·툴팁·
2
+ // HTML 소스 모드·첨부 패널·업로드 어댑터·폼 동기화를 담당한다.
3
+ //
4
+ // 마운트: 헬퍼(ore_editor)가 렌더한 [data-ore-editor] 루트를 OrdarsRailsEditor.mountAll() 이 잡는다.
5
+ // 동적 삽입(모달 AJAX 등)은 host 가 window.OrdarsRailsEditor.mountAll(container) 를 다시 호출.
6
+ //
7
+ // 업로드 계약(data-ore-upload-url 지정 시):
8
+ // POST {upload_url} multipart FormData { file } (+ X-CSRF-Token 헤더)
9
+ // 응답 JSON: { id, url, thumbnail_url?, file_name, file_size, file_type } (file_type: image|video|document|...)
10
+ // 실패: { error: "메시지" } — alert 대신 ore:error 이벤트 + 콘솔.
11
+ // 미지정 시(로컬 모드): 이미지 본문 삽입=dataURL, 첨부=목록 표시만(hidden input 없음).
12
+ //
13
+ // 저장 계약: 루트 내부 textarea.ore-input(hidden) 에 HTML 을 동기화(update 마다 + submit 시),
14
+ // 첨부는 .ore-attach-row 마다 hidden input[name={attach_name}][value=file_id].
15
+
16
+ import {
17
+ Editor, StarterKit, Underline, Link, TextStyle, Color, Highlight,
18
+ TextAlign, Image, Placeholder
19
+ } from "ordars_rails_editor/tiptap"
20
+
21
+ // 글자 크기 — TextStyle 마크에 fontSize 속성 추가
22
+ const SizedTextStyle = TextStyle.extend({
23
+ addAttributes() {
24
+ return {
25
+ ...this.parent?.(),
26
+ fontSize: {
27
+ default: null,
28
+ parseHTML: el => el.style.fontSize || null,
29
+ renderHTML: attrs => (attrs.fontSize ? { style: `font-size: ${attrs.fontSize}` } : {})
30
+ }
31
+ }
32
+ }
33
+ })
34
+
35
+ // 이미지 — data-file-id 마커 보존(렌더 시점 URL 치환의 근거)
36
+ const FileImage = Image.extend({
37
+ addAttributes() {
38
+ return {
39
+ ...this.parent?.(),
40
+ "data-file-id": { default: null }
41
+ }
42
+ }
43
+ })
44
+
45
+ const COLORS = ["#0f172a", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#2563eb", "#7c3aed", "#db2777", "#64748b", "#3e6187", "#0d9488", "#92400e", "#78716c"]
46
+ const HIGHLIGHTS = ["#fef08a", "#bbf7d0", "#bfdbfe", "#fbcfe8", "#fed7aa", "#e9d5ff"]
47
+
48
+ // ── 전역 말풍선 툴팁 (버튼 위) ──
49
+ let tipEl = null
50
+ let tipTimer = null
51
+ function ensureTip() {
52
+ if (tipEl) return tipEl
53
+ tipEl = document.createElement("div")
54
+ tipEl.className = "ore-tip"
55
+ document.body.appendChild(tipEl)
56
+ return tipEl
57
+ }
58
+ function bindTips(scope) {
59
+ scope.querySelectorAll("[data-tip]").forEach(el => {
60
+ if (el._oreTip) return
61
+ el._oreTip = true
62
+ el.addEventListener("mouseenter", () => {
63
+ clearTimeout(tipTimer)
64
+ tipTimer = setTimeout(() => {
65
+ const t = ensureTip()
66
+ t.textContent = el.dataset.tip
67
+ t.classList.add("show")
68
+ const r = el.getBoundingClientRect()
69
+ let x = r.left + r.width / 2 - t.offsetWidth / 2
70
+ x = Math.max(8, Math.min(x, window.innerWidth - t.offsetWidth - 8))
71
+ t.style.left = x + "px"
72
+ t.style.top = (r.top - t.offsetHeight - 8) + "px"
73
+ }, 120)
74
+ })
75
+ const hide = () => { clearTimeout(tipTimer); tipEl?.classList.remove("show") }
76
+ el.addEventListener("mouseleave", hide)
77
+ el.addEventListener("mousedown", hide)
78
+ })
79
+ }
80
+
81
+ export class OreEditor {
82
+ static instances = new WeakMap()
83
+
84
+ static mount(root) {
85
+ if (OreEditor.instances.has(root)) return OreEditor.instances.get(root)
86
+ const inst = new OreEditor(root)
87
+ OreEditor.instances.set(root, inst)
88
+ return inst
89
+ }
90
+
91
+ static mountAll(scope = document) {
92
+ scope.querySelectorAll("[data-ore-editor]").forEach(root => OreEditor.mount(root))
93
+ }
94
+
95
+ static get(root) { return OreEditor.instances.get(root) }
96
+
97
+ constructor(root) {
98
+ this.root = root
99
+ this.input = root.querySelector(".ore-input")
100
+ this.uploadUrl = root.dataset.oreUploadUrl || ""
101
+ this.attachName = root.dataset.oreAttachName || ""
102
+ this.limits = {
103
+ images: parseInt(root.dataset.oreMaxImages || "100", 10),
104
+ videos: parseInt(root.dataset.oreMaxVideos || "50", 10),
105
+ totalMb: parseFloat(root.dataset.oreMaxMb || "50")
106
+ }
107
+ this.state = { img: 0, vid: 0, bytes: 0 }
108
+ this.htmlMode = false
109
+
110
+ this.editor = new Editor({
111
+ element: root.querySelector(".ore-content"),
112
+ extensions: [
113
+ StarterKit,
114
+ Underline,
115
+ SizedTextStyle,
116
+ Color,
117
+ Highlight.configure({ multicolor: true }),
118
+ Link.configure({ openOnClick: false, autolink: true }),
119
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
120
+ FileImage.configure({ inline: false }),
121
+ Placeholder.configure({ placeholder: root.dataset.orePlaceholder || "내용을 입력하세요" })
122
+ ],
123
+ content: this.input ? this.input.value : "",
124
+ onUpdate: () => this.syncInput()
125
+ })
126
+
127
+ this.wireToolbar()
128
+ this.wirePalettes()
129
+ this.wireAttachments()
130
+ this.wireFormSync()
131
+ bindTips(root)
132
+ this.refresh = this.refresh.bind(this)
133
+ this.editor.on("transaction", this.refresh)
134
+ this.editor.on("selectionUpdate", this.refresh)
135
+ root.dispatchEvent(new CustomEvent("ore:mounted", { bubbles: true }))
136
+ }
137
+
138
+ destroy() {
139
+ this.editor?.destroy()
140
+ OreEditor.instances.delete(this.root)
141
+ }
142
+
143
+ getHTML() { return this.editor.getHTML() }
144
+ setContent(html) { this.editor.commands.setContent(html || "", true); this.syncInput() }
145
+
146
+ syncInput() {
147
+ if (!this.input) return
148
+ const html = this.editor.getHTML()
149
+ // 빈 문서는 빈 문자열로 (required 검증·plain 저장 판단이 쉬워지게)
150
+ this.input.value = this.editor.isEmpty ? "" : html
151
+ }
152
+
153
+ // ── 툴바 ──
154
+ wireToolbar() {
155
+ const chain = () => this.editor.chain().focus()
156
+ const cmds = {
157
+ bold: () => chain().toggleBold().run(),
158
+ italic: () => chain().toggleItalic().run(),
159
+ strike: () => chain().toggleStrike().run(),
160
+ underline: () => chain().toggleUnderline().run(),
161
+ code: () => chain().toggleCode().run(),
162
+ bulletList: () => chain().toggleBulletList().run(),
163
+ orderedList: () => chain().toggleOrderedList().run(),
164
+ blockquote: () => chain().toggleBlockquote().run(),
165
+ hr: () => chain().setHorizontalRule().run(),
166
+ alignLeft: () => chain().setTextAlign("left").run(),
167
+ alignCenter: () => chain().setTextAlign("center").run(),
168
+ alignRight: () => chain().setTextAlign("right").run(),
169
+ alignJustify: () => chain().setTextAlign("justify").run(),
170
+ undo: () => chain().undo().run(),
171
+ redo: () => chain().redo().run(),
172
+ link: () => {
173
+ const prev = this.editor.getAttributes("link").href || ""
174
+ const url = prompt("링크 URL (비우면 해제)", prev)
175
+ if (url === null) return
176
+ if (url === "") chain().unsetLink().run()
177
+ else chain().extendMarkRange("link").setLink({ href: url }).run()
178
+ },
179
+ html: () => this.toggleHtml()
180
+ }
181
+ this.root.querySelectorAll("[data-cmd]").forEach(b =>
182
+ b.addEventListener("click", () => cmds[b.dataset.cmd]?.()))
183
+
184
+ const fs = this.root.querySelector(".ore-fontsize")
185
+ fs?.addEventListener("change", e => {
186
+ const v = e.target.value
187
+ chain().setMark("textStyle", { fontSize: v || null }).run()
188
+ })
189
+
190
+ this.root.querySelector(".ore-imgbtn")?.addEventListener("click", () => this.pickImage())
191
+ }
192
+
193
+ refresh() {
194
+ const q = sel => this.root.querySelector(sel)
195
+ const marks = { bold: "bold", italic: "italic", strike: "strike", underline: "underline", code: "code", bulletList: "bulletList", orderedList: "orderedList", blockquote: "blockquote" }
196
+ for (const [cmd, name] of Object.entries(marks)) {
197
+ q(`[data-cmd="${cmd}"]`)?.classList.toggle("on", this.editor.isActive(name))
198
+ }
199
+ ;[["alignLeft", "left"], ["alignCenter", "center"], ["alignRight", "right"], ["alignJustify", "justify"]].forEach(([c, v]) =>
200
+ q(`[data-cmd="${c}"]`)?.classList.toggle("on", this.editor.isActive({ textAlign: v })))
201
+ q('[data-cmd="link"]')?.classList.toggle("on", this.editor.isActive("link"))
202
+ const fs = q(".ore-fontsize")
203
+ if (fs) fs.value = this.editor.getAttributes("textStyle").fontSize || ""
204
+ }
205
+
206
+ // ── 팔레트 (글자색·형광펜 — 해제는 마지막 ⌀ 스와치) ──
207
+ wirePalettes() {
208
+ const build = (palSel, colors, apply, clear) => {
209
+ const el = this.root.querySelector(palSel)
210
+ if (!el) return
211
+ colors.forEach(c => {
212
+ const b = document.createElement("button")
213
+ b.type = "button"; b.style.background = c
214
+ b.addEventListener("click", ev => { ev.stopPropagation(); apply(c); this.closePops() })
215
+ el.appendChild(b)
216
+ })
217
+ const x = document.createElement("button")
218
+ x.type = "button"; x.className = "clear"; x.title = "해제"
219
+ x.addEventListener("click", ev => { ev.stopPropagation(); clear(); this.closePops() })
220
+ el.appendChild(x)
221
+ }
222
+ build(".ore-colorpal", COLORS,
223
+ c => this.editor.chain().focus().setColor(c).run(),
224
+ () => this.editor.chain().focus().unsetColor().run())
225
+ build(".ore-hlpal", HIGHLIGHTS,
226
+ c => this.editor.chain().focus().setHighlight({ color: c }).run(),
227
+ () => this.editor.chain().focus().unsetHighlight().run())
228
+
229
+ this.root.querySelectorAll("[data-pop]").forEach(btn => {
230
+ btn.addEventListener("click", () => {
231
+ const pop = this.root.querySelector(btn.dataset.pop)
232
+ const was = pop?.classList.contains("open")
233
+ this.closePops()
234
+ if (pop && !was) pop.classList.add("open")
235
+ })
236
+ })
237
+ document.addEventListener("click", e => {
238
+ if (!e.target.closest || !e.target.closest(".ore-btnwrap")) this.closePops()
239
+ })
240
+ }
241
+
242
+ closePops() { this.root.querySelectorAll(".ore-pop").forEach(p => p.classList.remove("open")) }
243
+
244
+ // ── HTML 소스 모드 ──
245
+ toggleHtml() {
246
+ const view = this.root.querySelector(".ore-html")
247
+ if (!view) return
248
+ this.htmlMode = !this.htmlMode
249
+ this.root.classList.toggle("is-html", this.htmlMode)
250
+ this.root.querySelector('[data-cmd="html"]')?.classList.toggle("on", this.htmlMode)
251
+ if (this.htmlMode) view.value = this.editor.getHTML()
252
+ else { this.editor.commands.setContent(view.value, true); this.syncInput() }
253
+ }
254
+
255
+ // ── 이미지 본문 삽입 ──
256
+ pickImage() {
257
+ const pick = document.createElement("input")
258
+ pick.type = "file"; pick.accept = "image/*"; pick.multiple = true
259
+ pick.addEventListener("change", async () => {
260
+ for (const f of pick.files) {
261
+ if (this.uploadUrl) {
262
+ const meta = await this.upload(f)
263
+ if (!meta) continue
264
+ this.editor.chain().focus().setImage({ src: meta.url, "data-file-id": meta.id }).run()
265
+ this.addAttachRow(f, meta)
266
+ // 호스트 훅 — 본문 삽입 업로드를 호스트의 첨부 목록/수명주기와 연동할 때 사용
267
+ this.root.dispatchEvent(new CustomEvent("ore:uploaded", { bubbles: true, detail: { ...meta, inline: true } }))
268
+ } else {
269
+ const r = new FileReader()
270
+ r.onload = () => this.editor.chain().focus().setImage({ src: r.result }).run()
271
+ r.readAsDataURL(f)
272
+ this.addAttachRow(f, null)
273
+ }
274
+ }
275
+ this.syncInput()
276
+ })
277
+ pick.click()
278
+ }
279
+
280
+ // ── 첨부 패널 ──
281
+ wireAttachments() {
282
+ const list = this.root.querySelector(".ore-attachlist")
283
+ if (!list) return
284
+ const pickFiles = () => {
285
+ const pick = document.createElement("input")
286
+ pick.type = "file"; pick.multiple = true
287
+ pick.addEventListener("change", () => [...pick.files].forEach(f => this.attach(f)))
288
+ pick.click()
289
+ }
290
+ this.root.querySelector(".ore-filebtn")?.addEventListener("click", pickFiles)
291
+ list.addEventListener("dragover", e => e.preventDefault())
292
+ list.addEventListener("drop", e => { e.preventDefault(); [...e.dataTransfer.files].forEach(f => this.attach(f)) })
293
+ }
294
+
295
+ async attach(file) {
296
+ if (this.uploadUrl) {
297
+ const meta = await this.upload(file)
298
+ if (meta) {
299
+ this.addAttachRow(file, meta)
300
+ this.root.dispatchEvent(new CustomEvent("ore:uploaded", { bubbles: true, detail: { ...meta, inline: false } }))
301
+ }
302
+ } else {
303
+ this.addAttachRow(file, null)
304
+ }
305
+ }
306
+
307
+ async upload(file) {
308
+ try {
309
+ const fd = new FormData()
310
+ fd.append("file", file)
311
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content
312
+ const res = await fetch(this.uploadUrl, {
313
+ method: "POST", body: fd,
314
+ headers: csrf ? { "X-CSRF-Token": csrf } : {}
315
+ })
316
+ const json = await res.json().catch(() => ({}))
317
+ if (!res.ok || json.error || !json.url) {
318
+ this.emitError(json.error || `업로드 실패 (${res.status})`)
319
+ return null
320
+ }
321
+ return json
322
+ } catch (e) {
323
+ this.emitError("업로드 중 오류: " + e.message)
324
+ return null
325
+ }
326
+ }
327
+
328
+ emitError(message) {
329
+ this.root.dispatchEvent(new CustomEvent("ore:error", { bubbles: true, detail: { message } }))
330
+ if (window.showSnackbar) window.showSnackbar(message, "error")
331
+ else console.error("[ordars-rails-editor]", message)
332
+ }
333
+
334
+ addAttachRow(file, meta) {
335
+ const list = this.root.querySelector(".ore-attachlist")
336
+ if (!list) return
337
+ list.querySelector(".ore-attach-empty")?.remove()
338
+
339
+ const type = meta?.file_type || (file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : "file")
340
+ const size = meta?.file_size ?? file.size
341
+
342
+ const row = document.createElement("div")
343
+ row.className = "ore-attach-row"
344
+ if (type === "image") {
345
+ const t = document.createElement("img"); t.className = "th"
346
+ t.src = meta?.thumbnail_url || meta?.url || URL.createObjectURL(file)
347
+ row.appendChild(t)
348
+ } else {
349
+ const i = document.createElement("div"); i.className = "ic"
350
+ i.textContent = type === "video" ? "▶" : "📄"
351
+ row.appendChild(i)
352
+ }
353
+ const nm = document.createElement("span"); nm.className = "nm"; nm.textContent = meta?.file_name || file.name
354
+ const sz = document.createElement("span"); sz.className = "sz"; sz.textContent = (size / 1024 / 1024).toFixed(2) + "MB"
355
+ const rm = document.createElement("button"); rm.type = "button"; rm.className = "rm"; rm.textContent = "✕"; rm.title = "삭제"
356
+ row.append(nm, sz, rm)
357
+
358
+ if (meta?.id && this.attachName) {
359
+ const hid = document.createElement("input")
360
+ hid.type = "hidden"; hid.name = this.attachName; hid.value = meta.id
361
+ row.appendChild(hid)
362
+ }
363
+
364
+ rm.addEventListener("click", () => {
365
+ row.remove()
366
+ if (type === "image") this.state.img--
367
+ if (type === "video") this.state.vid--
368
+ this.state.bytes -= size
369
+ this.syncGauges()
370
+ if (!list.querySelector(".ore-attach-row")) this.renderEmpty(list)
371
+ })
372
+
373
+ list.appendChild(row)
374
+ if (type === "image") this.state.img++
375
+ if (type === "video") this.state.vid++
376
+ this.state.bytes += size
377
+ this.syncGauges()
378
+ }
379
+
380
+ renderEmpty(list) {
381
+ const d = document.createElement("div")
382
+ d.className = "ore-attach-empty"
383
+ d.textContent = "첨부된 파일이 없습니다. — [파일] 버튼 또는 이 영역에 드래그"
384
+ list.appendChild(d)
385
+ }
386
+
387
+ syncGauges() {
388
+ const q = sel => this.root.querySelector(sel)
389
+ const set = (sel, v) => { const el = q(sel); if (el) el.textContent = v }
390
+ set(".ore-cnt-img", this.state.img)
391
+ set(".ore-cnt-vid", this.state.vid)
392
+ const mb = this.state.bytes / 1024 / 1024
393
+ set(".ore-cap-used", mb.toFixed(2) + "MB")
394
+ const g = (sel, pct) => { const el = q(sel); if (el) el.style.width = Math.min(100, pct) + "%" }
395
+ g(".ore-g-img i", this.state.img / this.limits.images * 100)
396
+ g(".ore-g-vid i", this.state.vid / this.limits.videos * 100)
397
+ g(".ore-g-cap i", mb / this.limits.totalMb * 100)
398
+ }
399
+
400
+ // ── 폼 동기화 — submit 직전 최신 HTML 보장 (HTML 모드였으면 그 내용 반영) ──
401
+ wireFormSync() {
402
+ const form = this.root.closest("form")
403
+ if (!form || form._oreSync) return
404
+ form._oreSync = true
405
+ form.addEventListener("submit", () => {
406
+ OreEditor.instances.get(this.root) // noop — 인스턴스 생존 확인
407
+ if (this.htmlMode) this.toggleHtml()
408
+ this.syncInput()
409
+ })
410
+ }
411
+ }
412
+
413
+ function mountAll(scope) { OreEditor.mountAll(scope || document) }
414
+
415
+ window.OrdarsRailsEditor = { OreEditor, mount: OreEditor.mount.bind(OreEditor), mountAll, get: OreEditor.get.bind(OreEditor) }
416
+
417
+ document.addEventListener("DOMContentLoaded", () => mountAll())
418
+ document.addEventListener("turbo:load", () => mountAll())
419
+
420
+ export default OreEditor