Files
project-managment/templates/components/rich_text_editor_assets.html
T
sankalp 71b700388b feat(templates): add polished login UI with Tailwind and Alpine.js
Enhances user experience with dark‑mode detection, responsive layout, and streamlined role selection workflow
2026-06-28 14:15:39 +05:45

952 lines
40 KiB
HTML

<style>
.editor-wrap {
/* Light theme */
--bg: #ffffff;
--surface: #ffffff;
--surface2: #f9fafb;
--surface3: #f3f4f6;
--border: #e5e7eb;
--border2: #d1d5db;
--text: #111827;
--text-muted:#6b7280;
--accent: #2563eb;
--accent-bg: #eff6ff;
--green: #16a34a;
--red: #dc2626;
--yellow: #d97706;
--tab-active:#ffffff;
--radius: 8px;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.dark .editor-wrap {
/* Dark theme */
--bg: #030712;
--surface: #111827;
--surface2: #1f2937;
--surface3: #374151;
--border: #374151;
--border2: #4b5563;
--text: #f9fafb;
--text-muted:#9ca3af;
--accent: #3b82f6;
--accent-bg: #1e3a8a;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--tab-active:#111827;
}
.editor-wrap { width: 100%; max-width: 960px; position: relative; }
/* ── Mode switcher ── */
.editor-wrap .mode-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 10px;
flex-wrap: wrap;
}
.editor-wrap .mode-pills {
display: flex;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px;
gap: 2px;
}
.editor-wrap .mode-pill {
background: none;
border: none;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
font-family: var(--font-sans);
font-size: 12px;
font-weight: 500;
padding: 4px 12px;
transition: all .15s;
display: flex; align-items: center; gap: 5px;
}
.editor-wrap .mode-pill:hover { color: var(--text); }
.editor-wrap .mode-pill.active { background: var(--surface3); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,.4); }
/* ── Collab bar ── */
.editor-wrap .collab-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.editor-wrap .collab-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.editor-wrap .collab-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--text-muted);
transition: background .3s;
}
.editor-wrap .collab-dot.live { background: var(--green); box-shadow: 0 0 6px var(--green); }
.editor-wrap .peer-avatars { display: flex; gap: -4px; }
.editor-wrap .peer-avatar {
width: 26px; height: 26px;
border-radius: 50%;
border: 2px solid var(--bg);
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700;
color: #fff;
margin-left: -6px;
cursor: default;
position: relative;
transition: transform .15s;
}
.editor-wrap .peer-avatar:first-child { margin-left: 0; }
.editor-wrap .peer-avatar:hover { transform: translateY(-2px); z-index: 1; }
.editor-wrap .peer-avatar .peer-tip {
position: absolute;
bottom: calc(100% + 5px);
left: 50%;
transform: translateX(-50%);
background: #111827;
border: 1px solid #374151;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: #ffffff;
font-size: 11px;
padding: 3px 7px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity .1s;
}
.editor-wrap .peer-avatar:hover .peer-tip { opacity: 1; }
.editor-wrap .collab-link-btn {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
padding: 3px 9px;
transition: all .1s;
display: flex; align-items: center; gap: 5px;
}
.editor-wrap .collab-link-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Tabs ── */
.editor-wrap .tabs { display: flex; border-bottom: 1px solid var(--border); margin-bottom: -1px; align-items: flex-end; }
.editor-wrap .tab-btn {
background: none;
border: 1px solid transparent;
border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
color: var(--text-muted);
cursor: pointer;
font-family: var(--font-sans);
font-size: 14px;
padding: 8px 16px;
transition: color .15s;
}
.editor-wrap .tab-btn:hover { color: var(--text); }
.editor-wrap .tab-btn.active {
background: var(--tab-active);
border-color: var(--border);
border-bottom-color: var(--tab-active);
color: var(--text);
z-index: 1;
}
.editor-wrap .lang-select {
margin-left: auto;
margin-bottom: 1px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 12px;
padding: 4px 8px;
cursor: pointer;
outline: none;
}
.editor-wrap .lang-select option { background: var(--surface2); }
/* ── Toolbar ── */
.editor-wrap .toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-top: none;
flex-wrap: wrap;
}
.editor-wrap .toolbar-sep { width: 1px; height: 18px; background: var(--border2); margin: 0 4px; }
.editor-wrap .tool-btn {
background: none; border: none; border-radius: 4px;
color: var(--text-muted); cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
padding: 4px 6px;
transition: background .1s, color .1s;
position: relative;
}
.editor-wrap .tool-btn:hover { background: var(--border); color: var(--text); }
.editor-wrap .tool-btn svg { display: block; }
.editor-wrap .tool-btn::after {
content: attr(data-editor-tooltip);
position: absolute; bottom: calc(100% + 6px); left: 50%;
transform: translateX(-50%);
background: #111827; border: 1px solid #374151; border-radius: 6px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: #ffffff; font-size: 12px; padding: 4px 8px;
white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 100;
}
.editor-wrap .tool-btn:hover::after { opacity: 1; }
/* code editor toolbar extras */
.editor-wrap .code-toolbar-info {
margin-left: auto;
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 12px;
}
/* ── Write Panel ── */
.editor-wrap .write-panel {
background: var(--surface);
border: 1px solid var(--border);
border-top: none;
position: relative;
}
.editor-wrap .write-panel.drag-over { border-color: var(--accent); background: rgba(47,129,247,.05); }
.editor-wrap .write-panel:focus-within { border-color: var(--accent); }
.editor-wrap textarea {
background: transparent; border: none;
caret-color: var(--accent); color: var(--text);
display: block; font-family: var(--font-mono); font-size: 14px;
line-height: 1.6; min-height: 44px; max-height: 600px;
outline: none; padding: 10px 16px; resize: none; width: 100%;
overflow-y: auto; transition: min-height .15s ease;
}
.editor-wrap textarea::placeholder { color: var(--text-muted); }
.editor-wrap .drag-overlay {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center; gap: 10px;
background: rgba(47,129,247,.08); border: 2px dashed var(--accent);
border-radius: 0 0 var(--radius) var(--radius);
color: var(--accent); font-size: 15px; font-weight: 500;
pointer-events: none; z-index: 20;
}
.editor-wrap .attach-bar {
border-top: 1px solid var(--border);
padding: 8px 12px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.editor-wrap .attach-hint { color: var(--text-muted); font-size: 12px; flex: 1; display: flex; align-items: center; gap: 6px; }
.editor-wrap .attach-label {
cursor: pointer; color: var(--accent); font-size: 12px;
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px; transition: background .1s;
}
.editor-wrap .attach-label:hover { background: var(--border); }
.editor-wrap #file-input { display: none; }
.editor-wrap .img-previews { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 12px 10px; border-top: 1px solid var(--border); }
.editor-wrap .img-thumb { position: relative; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; width: 80px; height: 60px; flex-shrink: 0; }
.editor-wrap .img-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.editor-wrap .img-thumb .remove-btn {
position: absolute; top: 2px; right: 2px;
background: rgba(0,0,0,.75); border: none; border-radius: 50%;
color: #fff; cursor: pointer; width: 18px; height: 18px; font-size: 11px;
display: flex; align-items: center; justify-content: center;
}
.editor-wrap .img-thumb .remove-btn:hover { background: var(--red); }
.editor-wrap .img-thumb .uploading-bar { position: absolute; bottom: 0; left: 0; height: 3px; background: var(--accent); transition: width .3s; }
.editor-wrap .char-count { color: var(--text-muted); font-size: 12px; }
/* ── CodeMirror wrapper ── */
.editor-wrap .cm-wrap {
border: 1px solid var(--border);
border-top: none;
position: relative;
overflow: hidden;
}
.editor-wrap .cm-wrap:focus-within { border-color: var(--accent); }
/* CM6 theme overrides */
.editor-wrap .cm-editor {
background: var(--surface) !important;
color: var(--text) !important;
font-family: var(--font-mono) !important;
font-size: 13.5px !important;
min-height: 44px;
max-height: 600px;
}
.editor-wrap .cm-editor.cm-focused { outline: none !important; }
.editor-wrap .cm-scroller { overflow: auto !important; }
.editor-wrap .cm-gutters {
background: var(--surface2) !important;
border-right: 1px solid var(--border) !important;
color: var(--text-muted) !important;
min-width: 46px;
}
.editor-wrap .cm-activeLineGutter { background: rgba(47,129,247,.08) !important; color: var(--text) !important; }
.editor-wrap .cm-activeLine { background: rgba(47,129,247,.06) !important; }
.editor-wrap .cm-selectionBackground { background: rgba(47,129,247,.25) !important; }
.editor-wrap .cm-cursor { border-left-color: var(--accent) !important; border-left-width: 2px !important; }
.editor-wrap .cm-matchingBracket { background: rgba(63,185,80,.2) !important; color: var(--green) !important; outline: 1px solid var(--green) !important; }
.editor-wrap .cm-tooltip { background: var(--surface2) !important; border: 1px solid var(--border2) !important; border-radius: 4px !important; color: var(--text) !important; }
.editor-wrap .cm-tooltip-autocomplete > ul > li { padding: 3px 10px !important; }
.editor-wrap .cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent-bg) !important; color: var(--text) !important; }
.editor-wrap .cm-searchMatch { background: rgba(210,153,34,.3) !important; outline: 1px solid var(--yellow) !important; }
.editor-wrap .cm-searchMatch-selected { background: rgba(210,153,34,.6) !important; }
.editor-wrap .cm-panels { background: var(--surface2) !important; border-color: var(--border) !important; }
.editor-wrap .cm-panels input { background: var(--surface) !important; border: 1px solid var(--border2) !important; color: var(--text) !important; border-radius: 3px; padding: 2px 6px; outline: none; }
.editor-wrap .cm-panels button { background: var(--surface3) !important; border: 1px solid var(--border) !important; color: var(--text) !important; border-radius: 3px; padding: 2px 8px; cursor: pointer; }
/* Syntax highlighting */
.editor-wrap .tok-keyword { color: #ff7b72; }
.editor-wrap .tok-operator { color: #ff7b72; }
.editor-wrap .tok-string { color: #a5d6ff; }
.editor-wrap .tok-number { color: #79c0ff; }
.editor-wrap .tok-comment { color: #8b949e; font-style: italic; }
.editor-wrap .tok-function { color: #d2a8ff; }
.editor-wrap .tok-typeName { color: #ffa657; }
.editor-wrap .tok-className { color: #ffa657; }
.editor-wrap .tok-propertyName { color: #79c0ff; }
.editor-wrap .tok-variableName { color: #e6edf3; }
.editor-wrap .tok-bool { color: #79c0ff; }
.editor-wrap .tok-null { color: #79c0ff; }
.editor-wrap .tok-meta { color: #8b949e; }
.editor-wrap .tok-emphasis { font-style: italic; }
.editor-wrap .tok-strong { font-weight: bold; }
.editor-wrap .tok-heading { color: #79c0ff; font-weight: bold; }
.editor-wrap .tok-link { color: #a5d6ff; text-decoration: underline; }
.editor-wrap .tok-url { color: #a5d6ff; }
.editor-wrap .tok-tagName { color: #7ee787; }
.editor-wrap .tok-attributeName { color: #79c0ff; }
.editor-wrap .tok-attributeValue { color: #a5d6ff; }
.editor-wrap .tok-punctuation { color: #c9d1d9; }
/* ── Remote cursors ── */
.editor-wrap .remote-cursor-widget {
border-left: 2px solid;
border-radius: 1px;
margin-left: -1px;
pointer-events: none;
position: relative;
}
.editor-wrap .remote-cursor-label {
position: absolute;
top: -18px;
left: -1px;
font-size: 10px;
font-family: var(--font-sans);
padding: 1px 5px;
border-radius: 3px 3px 3px 0;
white-space: nowrap;
pointer-events: none;
color: #fff;
line-height: 1.5;
}
/* ── Preview Panel ── */
.editor-wrap .preview-panel {
background: var(--surface); border: 1px solid var(--border); border-top: none;
min-height: 120px; padding: 16px 20px;
}
.editor-wrap .md-preview { line-height: 1.7; color: var(--text); }
.editor-wrap .md-preview h1, .editor-wrap .md-preview h2, .editor-wrap .md-preview h3, .editor-wrap .md-preview h4, .editor-wrap .md-preview h5, .editor-wrap .md-preview h6 {
border-bottom: 1px solid var(--border); font-weight: 600; margin: 20px 0 12px; padding-bottom: 6px;
}
.editor-wrap .md-preview h1 { font-size: 1.8em; } .md-preview h2 { font-size: 1.4em; }
.editor-wrap .md-preview h3 { font-size: 1.15em; border-bottom: none; }
.editor-wrap .md-preview p { margin: 0 0 14px; }
.editor-wrap .md-preview a { color: var(--accent); text-decoration: none; }
.editor-wrap .md-preview a:hover { text-decoration: underline; }
.editor-wrap .md-preview code { background: rgba(110,118,129,.2); border-radius: 3px; font-family: var(--font-mono); font-size: 85%; padding: 2px 5px; }
.editor-wrap .md-preview pre { background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); margin: 0 0 14px; overflow: auto; padding: 14px 16px; }
.editor-wrap .md-preview pre code { background: none; font-size: 13px; padding: 0; }
.editor-wrap .md-preview blockquote { border-left: 4px solid var(--border2); color: var(--text-muted); margin: 0 0 14px; padding: 4px 14px; }
.editor-wrap .md-preview ul, .editor-wrap .md-preview ol { margin: 0 0 14px; padding-left: 24px; }
.editor-wrap .md-preview li { margin-bottom: 4px; }
.editor-wrap .md-preview hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.editor-wrap .md-preview table { border-collapse: collapse; margin-bottom: 14px; width: 100%; }
.editor-wrap .md-preview th, .editor-wrap .md-preview td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; }
.editor-wrap .md-preview th { background: var(--surface2); font-weight: 600; }
.editor-wrap .md-preview strong { font-weight: 600; } .md-preview em { font-style: italic; }
.editor-wrap .md-preview del { text-decoration: line-through; color: var(--text-muted); }
.editor-wrap .md-preview img { max-width: 100%; border-radius: var(--radius); }
.editor-wrap .md-preview .task-item { list-style: none; margin-left: -20px; }
.editor-wrap .md-preview .task-item input { margin-right: 6px; accent-color: var(--accent); }
.editor-wrap .md-preview .mention { color: var(--accent); background: rgba(47,129,247,.12); border-radius: 3px; padding: 1px 3px; font-weight: 500; }
.editor-wrap .preview-empty { color: var(--text-muted); font-style: italic; padding: 20px 0; }
/* ── Mention dropdown ── */
.editor-wrap .mention-dropdown {
position: absolute;
background: var(--surface2); border: 1px solid var(--border2);
border-radius: var(--radius); box-shadow: 0 8px 24px rgba(1,4,9,.6);
min-width: 220px; max-height: 220px; overflow-y: auto; z-index: 200;
}
.editor-wrap .mention-item { align-items: center; cursor: pointer; display: flex; gap: 10px; padding: 8px 12px; transition: background .1s; }
.editor-wrap .mention-item:hover, .editor-wrap .mention-item.active { background: var(--accent-bg); }
.editor-wrap .mention-avatar { width: 28px; height: 28px; border-radius: 50%; background: var(--surface3); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text); flex-shrink: 0; }
.editor-wrap .mention-info { display: flex; flex-direction: column; min-width: 0; }
.editor-wrap .mention-name { font-weight: 500; font-size: 13px; color: var(--text); }
.editor-wrap .mention-handle { font-size: 12px; color: var(--text-muted); }
/* ── Emoji picker ── */
.editor-wrap .emoji-picker {
position: absolute; background: var(--surface2); border: 1px solid var(--border2);
border-radius: var(--radius); box-shadow: 0 8px 24px rgba(1,4,9,.6); width: 300px; z-index: 200; overflow: hidden;
}
.editor-wrap .emoji-search-wrap { padding: 8px 10px; border-bottom: 1px solid var(--border); }
.editor-wrap .emoji-search { background: var(--surface); border: 1px solid var(--border2); border-radius: 4px; color: var(--text); font-size: 13px; outline: none; padding: 5px 10px; width: 100%; }
.editor-wrap .emoji-search::placeholder { color: var(--text-muted); }
.editor-wrap .emoji-category { font-size: 11px; color: var(--text-muted); padding: 6px 10px 2px; text-transform: uppercase; letter-spacing: .05em; }
.editor-wrap .emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); padding: 4px 6px 10px; max-height: 180px; overflow-y: auto; }
.editor-wrap .emoji-btn { background: none; border: none; border-radius: 4px; cursor: pointer; font-size: 18px; padding: 4px; transition: background .1s; text-align: center; }
.editor-wrap .emoji-btn:hover { background: var(--border); }
/* ── Footer ── */
.editor-wrap .footer-bar { display: flex; align-items: center; justify-content: space-between; padding: 10px 0 0; gap: 8px; flex-wrap: wrap; }
.editor-wrap .md-hint { align-items: center; color: var(--text-muted); display: inline-flex; font-size: 12px; gap: 5px; text-decoration: none; }
.editor-wrap .md-hint:hover { color: var(--text); }
.editor-wrap .btn-row { display: flex; gap: 8px; }
.editor-wrap .btn { border-radius: var(--radius); border: 1px solid transparent; cursor: pointer; font-family: var(--font-sans); font-size: 14px; font-weight: 500; padding: 6px 16px; transition: opacity .15s, background .15s; }
.editor-wrap .btn-ghost { background: var(--surface2); border-color: var(--border); color: var(--text); }
.editor-wrap .btn-ghost:hover { background: var(--border); }
.editor-wrap .btn-primary { background: #238636; border-color: #2ea043; color: #fff; }
.editor-wrap .btn-primary:hover { background: #2ea043; }
.editor-wrap .btn-primary:disabled { opacity: .4; cursor: not-allowed; }
/* ── Toast ── */
.editor-wrap-toast {
position: fixed; bottom: 24px; left: 50%;
transform: translateX(-50%) translateY(8px);
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); font-size: 13px; padding: 8px 18px;
opacity: 0; transition: opacity .2s, transform .2s; pointer-events: none; z-index: 999;
display: flex; align-items: center; gap: 8px;
}
.editor-wrap .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ── Status bar ── */
.editor-wrap .status-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 5px 12px;
background: var(--surface2);
border: 1px solid var(--border);
border-top: none;
font-size: 11px;
color: var(--text-muted);
}
.editor-wrap .status-bar span { display: flex; align-items: center; gap: 4px; }
.editor-wrap .status-bar .sep { color: var(--border2); }
.editor-wrap ::-webkit-scrollbar { width: 6px; height: 6px; }
.editor-wrap ::-webkit-scrollbar-track { background: transparent; }
.editor-wrap ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
</style>
<script>
// ── Fake users ──
const USERS = [
{ name: 'Sankalp Sharma', handle: 'sankalp', color: '#2f81f7' },
{ name: 'Alice Johnson', handle: 'alice', color: '#3fb950' },
{ name: 'Bob Ramirez', handle: 'bob', color: '#a371f7' },
{ name: 'Carol Chen', handle: 'carol', color: '#ffa657' },
{ name: 'Dave Kumar', handle: 'dave', color: '#f85149' },
{ name: 'Eva Müller', handle: 'eva', color: '#58a6ff' },
{ name: 'Frank Osei', handle: 'frank', color: '#3fb950' },
{ name: 'Grace Park', handle: 'grace', color: '#d2a8ff' },
];
const PEER_NAMES = ['Alice Johnson','Bob Ramirez','Carol Chen','Dave Kumar','Eva Müller','Frank Osei'];
const PEER_COLORS = ['#3fb950','#a371f7','#ffa657','#f85149','#58a6ff','#d2a8ff'];
const EMOJI_CATS = [
{ name: 'Smileys', emojis: ['😀','😃','😄','😁','😆','😅','😂','🤣','😊','😇','🙂','😉','😌','😍','🥰','😘','😋','😛','😝','😜','🤪','😎','🤩','😏','😒','😔','😟','😕','😢','😭','😤','😠','🤬','🤯','😳','😱','😨','😓'] },
{ name: 'Gestures', emojis: ['👋','✋','👌','✌️','🤞','👍','👎','✊','👊','👏','🙌','🙏','💪','🤳','💅','☝️','👆','👇','👈','👉'] },
{ name: 'Objects', emojis: ['💻','🖥','⌨️','🖱','📱','📷','📹','🔋','💡','🔦','📡','💾','💿','📀','🔑','🔧','⚙️','🛠','🔩','📎','✏️','📝','📋'] },
{ name: 'Nature', emojis: ['🌱','🌿','🍀','🌲','🌳','🌴','🌵','🌾','🌷','🌹','🌺','🌸','🌼','🌻','⭐','✨','⚡','🔥','🌈','☀️','🌤','⛅','🌧','❄️','💧','💦'] },
{ name: 'Food', emojis: ['🍕','🍔','🌮','🥙','🍳','🍲','🍞','🧀','🥩','🍗','🍿','🧁','🎂','🍰','🍩','🍪','🍫','☕','🍵','🍺','🥂','🍷'] },
{ name: 'Symbols', emojis: ['❤️','🧡','💛','💚','💙','💜','🖤','💔','💕','💞','💓','💗','💖','💘','✅','❌','⚠️','🔴','🟡','🟢','🔵','⚡','💯','🎯','🔥','💡','🚀','⭐','🏆','🎉','🎊','🎈'] },
];
// Shared across all editor instances on the same page so that same-page
// editors can identify and ignore each other's BroadcastChannel messages.
const PAGE_ID = Math.random().toString(36).slice(2);
function editor(initialValue = '') {
return {
// Mode
mode: 'markdown',
// Markdown state
tab: 'write',
content: initialValue,
codeDoc: initialValue,
preview: '',
uploads: [],
isDragging: false,
// Code editor state
codeLang: 'javascript',
cmLine: 1, cmCol: 1, cmSelLen: 0,
wordWrap: false,
_cmView: null,
// Collab state
// Unique per-page identifier so that editors on the same page don't
// interfere with each other (the BroadcastChannel is shared globally
// across tabs, but same-page instances should stay independent).
pageId: PAGE_ID,
myId: Math.random().toString(36).slice(2),
myName: 'You',
myColor: '#2f81f7',
peers: [],
collabActive: false,
_channel: null,
_heartbeatInterval: null,
_remoteCursors: {},
_knownPeers: [],
// Mention
showMention: false,
mentionQuery: '',
mentionStart: -1,
mentionIndex: 0,
mentionPos: { top: 0, left: 0 },
allUsers: USERS,
filteredUsers: [],
// Emoji
showEmoji: false,
emojiSearch: '',
emojiPos: { top: 0, left: 0 },
emojiCats: EMOJI_CATS,
filteredEmojiCats: EMOJI_CATS,
init() {
// Pick a random name/color for this session
const idx = Math.floor(Math.random() * PEER_NAMES.length);
this.myName = PEER_NAMES[idx];
this.myColor = PEER_COLORS[idx];
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && this.mode === 'markdown') {
if (e.key === 'b') { e.preventDefault(); this.wrap('**','**'); }
if (e.key === 'i') { e.preventDefault(); this.wrap('_','_'); }
if (e.key === 'k') { e.preventDefault(); this.insertLink(); }
}
});
// BroadcastChannel collab
this._channel = new BroadcastChannel('rte_collab_v1');
this._channel.onmessage = e => this._onCollab(e.data);
this._broadcast({ type: 'join', pageId: this.pageId, id: this.myId, name: this.myName, color: this.myColor });
this._heartbeatInterval = setInterval(() => {
this._broadcast({ type: 'heartbeat', pageId: this.pageId, id: this.myId, name: this.myName, color: this.myColor });
// Prune stale peers
const now = Date.now();
this.peers = this.peers.filter(p => now - p.lastSeen < 4000);
this.collabActive = this.peers.length > 0;
}, 1500);
},
_broadcast(msg) { this._channel && this._channel.postMessage(msg); },
_onCollab(msg) {
if (msg.id === this.myId) return;
if (msg.type === 'join' || msg.type === 'heartbeat') {
const existing = this.peers.find(p => p.id === msg.id);
if (existing) { existing.lastSeen = Date.now(); }
else { this.peers.push({ id: msg.id, name: msg.name, color: msg.color, lastSeen: Date.now() }); }
this.collabActive = this.peers.length > 0;
// Send current doc to new peer (only cross-page, skip same-page instances)
if (msg.type === 'join' && msg.pageId !== this.pageId && !this._knownPeers.includes(msg.id)) {
this._broadcast({ type: 'sync', pageId: this.pageId, id: this.myId, name: this.myName, color: this.myColor, content: this.content, codeDoc: this.codeDoc, mode: this.mode });
this._knownPeers.push(msg.id);
}
}
if (msg.type === 'sync') {
// Ignore syncs from editors on the same page (same-page isolation)
if (msg.pageId === this.pageId) return;
// Only apply cross-peer content when both editors share the same mode
// (prevents a wysiwyg description editor from pre-filling a markdown reply editor)
if (!this.content && !this.codeDoc && this.mode === msg.mode) {
this.content = msg.content || '';
this.codeDoc = msg.codeDoc || '';
if (this._cmView && msg.codeDoc) {
this._cmView.dispatch({ changes: { from: 0, to: this._cmView.state.doc.length, insert: msg.codeDoc } });
}
}
}
if (msg.type === 'content_change') {
if (msg.pageId !== this.pageId) this.content = msg.content;
}
if (msg.type === 'code_change') {
if (msg.pageId !== this.pageId && this._cmView) {
const cur = this._cmView.state.doc.toString();
if (cur !== msg.code) {
this._cmView.dispatch({ changes: { from: 0, to: cur.length, insert: msg.code } });
}
}
if (msg.pageId !== this.pageId) this.codeDoc = msg.code;
}
if (msg.type === 'cursor_move') {
this._remoteCursors[msg.id] = { pos: msg.pos, name: msg.name, color: msg.color };
this._updateRemoteCursors();
}
if (msg.type === 'leave') {
this.peers = this.peers.filter(p => p.id !== msg.id);
delete this._remoteCursors[msg.id];
this._updateRemoteCursors();
this.collabActive = this.peers.length > 0;
}
},
copyCollabHint() {
navigator.clipboard.writeText(window.location.href).then(() =>
this.showToast('🔗', 'URL copied — open in another tab to collaborate!')
);
},
// ── Markdown helpers ──
getTA() { return this.$refs.ta; },
getSelection() {
const ta = this.getTA();
if (!ta) return { start: 0, end: 0, text: '' };
return { start: ta.selectionStart, end: ta.selectionEnd, text: ta.value.slice(ta.selectionStart, ta.selectionEnd) };
},
setSelection(start, end) {
const ta = this.getTA(); if (!ta) return;
ta.focus(); ta.setSelectionRange(start, end);
},
wrap(before, after) {
const ta = this.getTA(); if (!ta) return;
const { start, end, text } = this.getSelection();
const placeholder = text || 'text';
const newText = before + placeholder + after;
this.content = this.content.slice(0, start) + newText + this.content.slice(end);
this.$nextTick(() => { const s = start + before.length; this.setSelection(s, s + placeholder.length); });
},
insertAtLine(prefix) {
const ta = this.getTA(); if (!ta) return;
const pos = ta.selectionStart;
const lineStart = this.content.lastIndexOf('\n', pos - 1) + 1;
this.content = this.content.slice(0, lineStart) + prefix + this.content.slice(lineStart);
this.$nextTick(() => this.setSelection(pos + prefix.length, pos + prefix.length));
},
insertList(prefix) {
const { start, end, text } = this.getSelection();
if (text) {
const lined = text.split('\n').map(l => prefix + l).join('\n');
this.content = this.content.slice(0, start) + lined + this.content.slice(end);
this.$nextTick(() => this.setSelection(start, start + lined.length));
} else { this.insertAtLine(prefix); }
},
insertLink() {
const { text } = this.getSelection();
const label = text || 'link text';
const snippet = `[${label}](url)`;
const ta = this.getTA(); if (!ta) return;
const { start, end } = this.getSelection();
this.content = this.content.slice(0, start) + snippet + this.content.slice(end);
this.$nextTick(() => this.setSelection(start + label.length + 3, start + snippet.length - 1));
},
insertCodeBlock() {
const ta = this.getTA(); if (!ta) return;
const { start, end, text } = this.getSelection();
const inner = text || 'code here';
const snippet = '```\n' + inner + '\n```';
this.content = this.content.slice(0, start) + snippet + this.content.slice(end);
this.$nextTick(() => this.setSelection(start + 4, start + 4 + inner.length));
},
insertTable() {
const ta = this.getTA(); if (!ta) return;
const { start, end } = this.getSelection();
const snippet = '\n| Header | Header |\n| --- | --- |\n| Cell | Cell |\n| Cell | Cell |\n';
this.content = this.content.slice(0, start) + snippet + this.content.slice(end);
this.$nextTick(() => this.setSelection(start + snippet.length, start + snippet.length));
},
insertHR() {
const ta = this.getTA(); if (!ta) return;
const { start, end } = this.getSelection();
const snippet = '\n---\n';
this.content = this.content.slice(0, start) + snippet + this.content.slice(end);
this.$nextTick(() => this.setSelection(start + snippet.length, start + snippet.length));
},
autoResize() {
const ta = this.getTA(); if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 600) + 'px';
},
// ── Mention ──
insertMentionTrigger() {
const ta = this.getTA(); if (!ta) return;
const { start, end } = this.getSelection();
this.content = this.content.slice(0, start) + '@' + this.content.slice(end);
this.$nextTick(() => { this.setSelection(start + 1, start + 1); this.openMentionDropdown(start); });
},
openMentionDropdown(atPos) {
this.mentionStart = atPos;
this.mentionQuery = '';
this.filteredUsers = this.allUsers.slice(0, 8);
this.mentionIndex = 0;
this.showMention = true;
this.$nextTick(() => this.updateMentionPos(atPos));
},
updateMentionPos(atPos) {
const ta = this.getTA(); if (!ta) return;
const rect = ta.getBoundingClientRect();
const wRect = ta.closest('.editor-wrap').getBoundingClientRect();
this.mentionPos = { top: rect.bottom - wRect.top + 4, left: rect.left - wRect.left };
},
handleMentionSearch(query) {
this.mentionQuery = query;
const q = query.toLowerCase();
this.filteredUsers = this.allUsers.filter(u => u.name.toLowerCase().includes(q) || u.handle.toLowerCase().includes(q)).slice(0, 8);
this.mentionIndex = 0;
},
selectMention(user) {
const ta = this.getTA(); if (!ta) return;
const before = this.content.slice(0, this.mentionStart);
const after = this.content.slice(ta.selectionStart);
const insert = `@${user.handle} `;
this.content = before + insert + after;
this.showMention = false;
this.$nextTick(() => { const pos = this.mentionStart + insert.length; this.setSelection(pos, pos); });
},
// ── Emoji ──
toggleEmojiPicker(evt) {
if (this.showEmoji) { this.showEmoji = false; return; }
const btn = evt.currentTarget;
const wrapRect = btn.closest('.editor-wrap').getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
this.emojiPos = { top: btnRect.bottom - wrapRect.top + 6, left: Math.min(btnRect.left - wrapRect.left, wrapRect.width - 310) };
this.emojiSearch = '';
this.filteredEmojiCats = this.emojiCats;
this.showEmoji = true;
this.$nextTick(() => this.$refs.emojiInput && this.$refs.emojiInput.focus());
},
filterEmoji() {
const q = this.emojiSearch.toLowerCase();
if (!q) { this.filteredEmojiCats = this.emojiCats; return; }
this.filteredEmojiCats = this.emojiCats.filter(c => c.name.toLowerCase().includes(q));
},
insertEmoji(emoji) {
if (this.mode === 'code' && this._cmView) {
const sel = this._cmView.state.selection.main;
this._cmView.dispatch({ changes: { from: sel.from, to: sel.to, insert: emoji } });
} else {
const ta = this.getTA();
const { start, end } = this.getSelection();
this.content = this.content.slice(0, start) + emoji + this.content.slice(end);
this.$nextTick(() => this.setSelection(start + emoji.length, start + emoji.length));
ta && ta.focus();
}
this.showEmoji = false;
},
closePickers() { this.showMention = false; this.showEmoji = false; },
// ── Input handler ──
handleInput(e) {
this.autoResize();
this._broadcast({ type: 'content_change', id: this.myId, content: this.content });
const ta = this.getTA(); if (!ta) return;
const pos = ta.selectionStart;
const textBefore = this.content.slice(0, pos);
const lineStart = textBefore.lastIndexOf('\n') + 1;
const lineText = textBefore.slice(lineStart);
const atIdx = lineText.lastIndexOf('@');
if (atIdx !== -1) {
const query = lineText.slice(atIdx + 1);
if (!query.includes(' ') && !query.includes('\n')) {
const absAt = lineStart + atIdx;
if (!this.showMention || this.mentionStart !== absAt) {
this.mentionStart = absAt;
this.updateMentionPos(absAt);
this.showMention = true;
}
this.handleMentionSearch(query);
return;
}
}
this.showMention = false;
},
handleKeydown(e) {
if (this.showMention) {
if (e.key === 'ArrowDown') { e.preventDefault(); this.mentionIndex = Math.min(this.mentionIndex + 1, this.filteredUsers.length - 1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); this.mentionIndex = Math.max(this.mentionIndex - 1, 0); return; }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (this.filteredUsers[this.mentionIndex]) this.selectMention(this.filteredUsers[this.mentionIndex]); return; }
if (e.key === 'Escape') { this.showMention = false; return; }
}
if (this.showEmoji && e.key === 'Escape') { this.showEmoji = false; return; }
if (e.key === 'Tab') {
e.preventDefault();
const ta = this.getTA(); if (!ta) return;
const { start, end } = this.getSelection();
this.content = this.content.slice(0, start) + ' ' + this.content.slice(end);
this.$nextTick(() => this.setSelection(start + 2, start + 2));
}
},
// ── Image upload ──
handleDrop(e) {
this.isDragging = false;
[...e.dataTransfer.files].filter(f => f.type.startsWith('image/')).forEach(f => this.processImageFile(f));
},
handlePaste(e) {
const items = [...(e.clipboardData?.items || [])].filter(i => i.type.startsWith('image/'));
if (!items.length) return;
e.preventDefault();
items.forEach(item => { const f = item.getAsFile(); if (f) this.processImageFile(f); });
},
handleFileInput(e) {
[...e.target.files].forEach(f => this.processImageFile(f));
e.target.value = '';
},
processImageFile(file) {
const id = Date.now() + Math.random();
const entry = { id, name: file.name, dataUrl: '', uploading: true, progress: 0 };
this.uploads.push(entry);
const reader = new FileReader();
reader.onload = ev => {
const idx = this.uploads.findIndex(u => u.id === id);
if (idx === -1) return;
this.uploads[idx].dataUrl = ev.target.result;
let p = 0;
const interval = setInterval(() => {
p += Math.random() * 25 + 10;
if (p >= 100) {
p = 100; clearInterval(interval);
this.uploads[idx].uploading = false;
const safeName = file.name.replace(/[^\w.-]/g, '_');
this.content += `\n![${safeName}](${ev.target.result.slice(0,60)}...)\n`;
this.showToast('✓', `Image attached: ${file.name}`);
}
this.uploads[idx].progress = p;
}, 120);
};
reader.readAsDataURL(file);
},
removeUpload(idx) { this.uploads.splice(idx, 1); },
// ── Markdown renderer ──
renderPreview() {
if (!this.content.trim()) { this.preview = ''; return; }
let html = this.content;
const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const codeBlocks = [];
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { codeBlocks.push(`<pre><code>${esc(code.trim())}</code></pre>`); return `\x00cb${codeBlocks.length-1}\x00`; });
const inlineCodes = [];
html = html.replace(/`([^`]+)`/g, (_, code) => { inlineCodes.push(`<code>${esc(code)}</code>`); return `\x00ic${inlineCodes.length-1}\x00`; });
html = html.replace(/^######\s+(.+)$/gm,'<h6>$1</h6>').replace(/^#####\s+(.+)$/gm,'<h5>$1</h5>')
.replace(/^####\s+(.+)$/gm,'<h4>$1</h4>').replace(/^###\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^##\s+(.+)$/gm,'<h2>$1</h2>').replace(/^#\s+(.+)$/gm,'<h1>$1</h1>');
html = html.replace(/^(?:---|\*\*\*|___)\s*$/gm,'<hr>');
html = html.replace(/^>\s?(.*)$/gm,'<blockquote>$1</blockquote>');
html = html.replace(/^\|(.+)\|\n\|[-| :]+\|\n((?:\|.+\|\n?)+)/gm, (_, head, body) => {
const th = head.split('|').filter(c=>c.trim()).map(c=>`<th>${c.trim()}</th>`).join('');
const rows = body.trim().split('\n').map(r => `<tr>${r.split('|').filter(c=>c.trim()).map(c=>`<td>${c.trim()}</td>`).join('')}</tr>`).join('');
return `<table><thead><tr>${th}</tr></thead><tbody>${rows}</tbody></table>`;
});
html = html.replace(/^- \[x\]\s+(.+)$/gm,'<li class="task-item"><input type="checkbox" checked disabled> $1</li>');
html = html.replace(/^- \[ \]\s+(.+)$/gm,'<li class="task-item"><input type="checkbox" disabled> $1</li>');
html = html.replace(/((?:^- .+\n?)+)/gm, m => `<ul>${m.trim().split('\n').map(l=>`<li>${l.replace(/^- /,'')}</li>`).join('')}</ul>`);
html = html.replace(/((?:^\d+\. .+\n?)+)/gm, m => `<ol>${m.trim().split('\n').map(l=>`<li>${l.replace(/^\d+\. /,'')}</li>`).join('')}</ol>`);
html = html.replace(/\*\*\*(.+?)\*\*\*/g,'<strong><em>$1</em></strong>').replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/__(.+?)__/g,'<strong>$1</strong>').replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/_(.+?)_/g,'<em>$1</em>').replace(/~~(.+?)~~/g,'<del>$1</del>');
html = html.replace(/@([\w-]+)/g, (_, handle) => USERS.find(u=>u.handle===handle) ? `<span class="mention">@${handle}</span>` : `@${handle}`);
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,'<img src="$2" alt="$1">');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
const lines = html.split('\n');
const result = [];
const blockTags = /^<(h[1-6]|ul|ol|li|blockquote|hr|pre|table|thead|tbody|tr|th|td)/;
for (const line of lines) {
if (blockTags.test(line.trim()) || line.trim() === '') { result.push(line); }
else { result.push(line); }
}
html = result.join('\n');
html = html.replace(/\x00ic(\d+)\x00/g,(_,i)=>inlineCodes[+i]).replace(/\x00cb(\d+)\x00/g,(_,i)=>codeBlocks[+i]);
this.preview = html;
},
clearContent() {
this.content = ''; this.preview = ''; this.uploads = ''; this.tab = 'write';
this.$nextTick(() => { this.getTA()?.focus(); this.autoResize(); });
},
submit() { this.showToast('🚀', 'Submitted!'); },
showToast(icon, msg) {
const t = this.$refs.toast;
this.$refs.toastIcon.textContent = icon;
this.$refs.toastMsg.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2400);
},
};
}
</script>