/* 약관(Markdown) 편집기.
* - 기본은 textarea: 사용자가 직접 #, - 등을 입력할 수 있다.
* - "/" 를 줄 맨 앞 또는 빈 공간 다음에 입력하면 슬래시 메뉴를 띄워
* 제목/내용/글머리/번호/토글/구분선/인용/코드 블록을 선택해 자동 삽입한다.
* (사용자가 #, - 같은 기호를 외울 필요 없이 명령어로 입력 가능)
* - 미리보기 탭에서 작은 markdown → HTML 렌더러로 결과를 보여 준다.
*/
(function () {
'use strict'
var editor = document.getElementById('editor')
var preview = document.getElementById('preview')
var slashMenu = document.getElementById('slashMenu')
var status = document.getElementById('status')
var dirtyMark = document.getElementById('dirty-mark')
var saveBtn = document.getElementById('saveBtn')
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
var visInstaller = document.getElementById('visInstaller')
var visInstallerRp = document.getElementById('visInstallerRp')
editor.value = INITIAL || ''
var dirty = false
function setDirty(v) {
dirty = v
dirtyMark.hidden = !v
}
// 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다.
if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) })
if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) })
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
function escHtml(s) {
return s.replace(/&/g, '&').replace(//g, '>')
}
function inline(s) {
s = escHtml(s)
// code `x`
s = s.replace(/`([^`]+)`/g, '$1')
// bold **x**
s = s.replace(/\*\*([^*]+)\*\*/g, '$1')
// italic *x*
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1$2')
// links [text](url) — also auto-link bare http(s)
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
return p + '' + u + ''
})
return s
}
function renderMd(src) {
var lines = src.replace(/\r\n/g, '\n').split('\n')
var out = []
var i = 0
var stackList = null // 'ul' | 'ol' | null
function closeList() { if (stackList) { out.push('' + stackList + '>'); stackList = null } }
while (i < lines.length) {
var line = lines[i]
// 코드 블록 ```lang
var fence = /^```(\w*)\s*$/.exec(line)
if (fence) {
closeList()
var code = []
i += 1
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
code.push(lines[i]); i += 1
}
if (i < lines.length) i += 1
out.push('
' + escHtml(code.join('\n')) + '
')
continue
}
// 토글 (자체 구문) :::toggle 제목 ... :::
var togStart = /^:::toggle\s+(.+)$/.exec(line)
if (togStart) {
closeList()
var summary = togStart[1]
var body = []
i += 1
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
body.push(lines[i]); i += 1
}
if (i < lines.length) i += 1
out.push('' + inline(summary) + '
' + renderMd(body.join('\n')) + ' ')
continue
}
// 헤딩
var h = /^(#{1,6})\s+(.*)$/.exec(line)
if (h) {
closeList()
var level = h[1].length
out.push('' + inline(h[2]) + '')
i += 1; continue
}
// hr
if (/^---+\s*$/.test(line)) {
closeList()
out.push('
'); i += 1; continue
}
// 인용 >
if (/^>\s?/.test(line)) {
closeList()
var q = []
while (i < lines.length && /^>\s?/.test(lines[i])) {
q.push(lines[i].replace(/^>\s?/, '')); i += 1
}
out.push('' + renderMd(q.join('\n')) + '
')
continue
}
// 번호 목록
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
if (ol) {
if (stackList !== 'ol') { closeList(); out.push(''); stackList = 'ol' }
out.push('- ' + inline(ol[1]) + '
')
i += 1; continue
}
// 불릿
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
if (ul) {
if (stackList !== 'ul') { closeList(); out.push(''); stackList = 'ul' }
out.push('- ' + inline(ul[1]) + '
')
i += 1; continue
}
// 빈 줄
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
// 일반 문단
closeList()
var para = [line]; i += 1
while (i < lines.length && !/^\s*$/.test(lines[i])
&& !/^(#{1,6})\s+/.test(lines[i])
&& !/^\s*[-*]\s+/.test(lines[i])
&& !/^\s*\d+\.\s+/.test(lines[i])
&& !/^>/.test(lines[i])
&& !/^---+\s*$/.test(lines[i])
&& !/^```/.test(lines[i])
&& !/^:::/.test(lines[i])) {
para.push(lines[i]); i += 1
}
out.push('' + inline(para.join('\n').replace(/\n/g, '
')) + '
')
}
closeList()
return out.join('\n')
}
function refreshPreview() {
preview.innerHTML = renderMd(editor.value)
}
// ─── 탭 전환 (edit / preview) ────────────────────────────────────────
tabBtns.forEach(function (b) {
b.addEventListener('click', function () {
tabBtns.forEach(function (x) { x.classList.remove('active') })
b.classList.add('active')
var mode = b.getAttribute('data-mode')
if (mode === 'preview') {
refreshPreview()
editor.hidden = true
preview.hidden = false
} else {
editor.hidden = false
preview.hidden = true
}
})
})
// ─── 저장 ───────────────────────────────────────────────────────────
function save() {
status.classList.remove('error')
status.textContent = I18N.saving
var payload = { content: editor.value }
if (visInstaller) payload.showInInstaller = !!visInstaller.checked
if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
}).then(function (res) {
if (!res.ok) throw new Error((res.body && res.body.message) || 'failed')
setDirty(false)
status.textContent = I18N.saved
}).catch(function (err) {
status.classList.add('error')
status.textContent = I18N.saveFailed.replace('{{message}}', err.message)
})
}
saveBtn.addEventListener('click', save)
// Ctrl+S 저장
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault(); save()
}
})
// 페이지 떠나기 가드
window.addEventListener('beforeunload', function (e) {
if (!dirty) return
e.preventDefault()
e.returnValue = I18N.leaveConfirm
return I18N.leaveConfirm
})
editor.addEventListener('input', function () {
setDirty(true)
})
// ─── 슬래시 자동완성 ─────────────────────────────────────────────────
// 정의: { label, hint, insert: 줄 시작에 들어갈 텍스트 (커서 위치는 |로 표시) }
var SLASH_ITEMS = [
{ label: I18N.slashHeading1, hint: '# ', insert: '# |' },
{ label: I18N.slashHeading2, hint: '## ', insert: '## |' },
{ label: I18N.slashHeading3, hint: '### ', insert: '### |' },
{ label: I18N.slashText, hint: '', insert: '|' },
{ label: I18N.slashBullet, hint: '- ', insert: '- |' },
{ label: I18N.slashNumbered, hint: '1. ', insert: '1. |' },
{ label: I18N.slashToggle, hint: ':::toggle 제목 ... :::', insert: ':::toggle 제목\n|\n:::' },
{ label: I18N.slashDivider, hint: '---', insert: '---\n|' },
{ label: I18N.slashQuote, hint: '> ', insert: '> |' },
{ label: I18N.slashCode, hint: '```', insert: '```\n|\n```' }
]
var slashState = null // { startPos: number, query: string, activeIndex: number, filtered: [] }
function renderSlashItems(filtered) {
slashMenu.innerHTML = ''
filtered.forEach(function (item, idx) {
var el = document.createElement('div')
el.className = 'slashItem' + (idx === slashState.activeIndex ? ' active' : '')
var strong = document.createElement('strong')
strong.textContent = item.label
var span = document.createElement('span')
span.textContent = item.hint || ''
el.appendChild(strong); el.appendChild(span)
el.addEventListener('mousedown', function (e) {
e.preventDefault()
applySlash(item)
})
slashMenu.appendChild(el)
})
}
function positionSlash() {
// textarea caret 좌표 근사: 보이지 않는 mirror div 를 만들어 caret 위치를 추정한다.
var rect = editor.getBoundingClientRect()
var wrapRect = editor.parentElement.getBoundingClientRect()
var caret = getCaretCoords(editor)
var top = caret.top + 22 + (rect.top - wrapRect.top) - editor.scrollTop
var left = caret.left + (rect.left - wrapRect.left)
slashMenu.style.top = top + 'px'
slashMenu.style.left = left + 'px'
}
function openSlash() {
slashState = {
startPos: editor.selectionStart - 1, // '/' 위치
query: '',
activeIndex: 0,
filtered: SLASH_ITEMS.slice()
}
renderSlashItems(slashState.filtered)
slashMenu.hidden = false
positionSlash()
}
function closeSlash() {
slashState = null
slashMenu.hidden = true
}
function applySlash(item) {
if (!slashState) return
var value = editor.value
var start = slashState.startPos
var end = editor.selectionStart
// 줄의 시작 위치 계산 (이미 '#', '- ' 같은 prefix 가 있어도 무시하고 새 prefix 로 교체)
var lineStart = value.lastIndexOf('\n', start - 1) + 1
var lineEnd = value.indexOf('\n', end)
if (lineEnd === -1) lineEnd = value.length
var beforeLine = value.slice(0, lineStart)
var afterLine = value.slice(lineEnd)
var currentLine = value.slice(lineStart, lineEnd)
// 줄 안에서 '/검색어' 부분을 제거하고, 나머지 텍스트를 prefix 뒤에 이어 붙인다.
var rest = currentLine.slice(0, start - lineStart) + currentLine.slice(end - lineStart)
var insert = item.insert
var caretMarker = insert.indexOf('|')
var inserted = insert.replace('|', rest)
editor.value = beforeLine + inserted + afterLine
var caretPos = (beforeLine + insert.slice(0, caretMarker)).length
editor.selectionStart = editor.selectionEnd = caretPos
closeSlash()
setDirty(true)
editor.focus()
}
editor.addEventListener('keydown', function (e) {
if (slashState) {
if (e.key === 'ArrowDown') {
e.preventDefault()
slashState.activeIndex = (slashState.activeIndex + 1) % slashState.filtered.length
renderSlashItems(slashState.filtered)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
slashState.activeIndex = (slashState.activeIndex - 1 + slashState.filtered.length) % slashState.filtered.length
renderSlashItems(slashState.filtered)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
if (slashState.filtered.length > 0) {
e.preventDefault()
applySlash(slashState.filtered[slashState.activeIndex])
return
}
}
if (e.key === 'Escape') {
e.preventDefault()
closeSlash()
return
}
}
})
editor.addEventListener('input', function (e) {
var pos = editor.selectionStart
var ch = editor.value.slice(pos - 1, pos)
if (!slashState && ch === '/') {
// 줄 시작 또는 공백 다음에서만 슬래시 메뉴 활성화
var prev = pos >= 2 ? editor.value.slice(pos - 2, pos - 1) : '\n'
if (prev === '\n' || prev === ' ' || pos === 1) {
openSlash()
return
}
}
if (slashState) {
var startPos = slashState.startPos
if (pos < startPos || editor.value[startPos] !== '/') {
closeSlash()
return
}
var q = editor.value.slice(startPos + 1, pos).toLowerCase()
slashState.query = q
slashState.filtered = SLASH_ITEMS.filter(function (it) {
if (!q) return true
return it.label.toLowerCase().indexOf(q) !== -1
|| (it.hint && it.hint.toLowerCase().indexOf(q) !== -1)
})
slashState.activeIndex = 0
renderSlashItems(slashState.filtered)
positionSlash()
}
})
editor.addEventListener('blur', function () {
// mousedown on menu uses e.preventDefault → blur 시에도 안전하게 닫는다.
setTimeout(closeSlash, 100)
})
// ─── caret 좌표 계산 (mirror div 기법) ───────────────────────────────
function getCaretCoords(el) {
var div = document.createElement('div')
var s = getComputedStyle(el)
var props = [
'boxSizing','width','height','overflowX','overflowY',
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
'paddingTop','paddingRight','paddingBottom','paddingLeft',
'fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust',
'lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration',
'letterSpacing','wordSpacing','tabSize','MozTabSize','whiteSpace'
]
div.style.position = 'absolute'
div.style.visibility = 'hidden'
div.style.whiteSpace = 'pre-wrap'
div.style.wordWrap = 'break-word'
props.forEach(function (p) { div.style[p] = s[p] })
div.style.position = 'absolute'
div.style.top = '0'
div.style.left = '0'
var rect = el.getBoundingClientRect()
document.body.appendChild(div)
var pos = el.selectionStart
var before = el.value.substring(0, pos)
div.textContent = before
var span = document.createElement('span')
span.textContent = el.value.substring(pos) || '.'
div.appendChild(span)
var top = span.offsetTop + parseInt(s.borderTopWidth, 10)
var left = span.offsetLeft + parseInt(s.borderLeftWidth, 10)
document.body.removeChild(div)
return { top: top, left: left }
}
})()