Skip to content

Commit 405de06

Browse files
committed
ctrl-enter behavior, table exit,
1 parent 86dd42a commit 405de06

4 files changed

Lines changed: 478 additions & 53 deletions

File tree

src/blazer-markdown-editor.css

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,85 @@
5353
padding: 4px 16px;
5454
color: #555;
5555
}
56+
57+
.table-toolbar,
58+
.link-toolbar {
59+
position: absolute;
60+
z-index: 100;
61+
background: #fff;
62+
border: 1px solid #d0d0d0;
63+
border-radius: 8px;
64+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
65+
padding: 4px;
66+
display: flex;
67+
gap: 2px;
68+
}
69+
.table-toolbar button,
70+
.link-toolbar button {
71+
padding: 4px 8px;
72+
border: none;
73+
background: none;
74+
border-radius: 4px;
75+
cursor: pointer;
76+
font-size: 12px;
77+
white-space: nowrap;
78+
color: #333;
79+
font-family: inherit;
80+
}
81+
.table-toolbar button:hover,
82+
.link-toolbar button:hover {
83+
background: #f0f0ff;
84+
}
85+
.table-toolbar .sep {
86+
width: 1px;
87+
background: #e0e0e0;
88+
margin: 2px 3px;
89+
}
90+
.table-toolbar .danger:hover {
91+
background: #fff0f0;
92+
color: #c00;
93+
}
94+
95+
.slash-menu {
96+
position: absolute;
97+
z-index: 100;
98+
background: #fff;
99+
border: 1px solid #d0d0d0;
100+
border-radius: 8px;
101+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
102+
padding: 4px;
103+
min-width: 220px;
104+
max-height: 320px;
105+
overflow-y: auto;
106+
}
107+
.slash-menu-item {
108+
display: flex;
109+
align-items: center;
110+
gap: 10px;
111+
padding: 8px 12px;
112+
border-radius: 5px;
113+
cursor: pointer;
114+
font-size: 14px;
115+
}
116+
.slash-menu-item:hover,
117+
.slash-menu-item.active {
118+
background: #f0f0ff;
119+
}
120+
.slash-menu-item .icon {
121+
width: 28px;
122+
height: 28px;
123+
display: flex;
124+
align-items: center;
125+
justify-content: center;
126+
background: #f0f0f0;
127+
border-radius: 5px;
128+
font-size: 15px;
129+
flex-shrink: 0;
130+
}
131+
.slash-menu-item .label {
132+
font-weight: 500;
133+
}
134+
.slash-menu-item .desc {
135+
font-size: 12px;
136+
color: #888;
137+
}

src/blazer-markdown-editor.js

Lines changed: 168 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import {
3333
setBlockType,
3434
wrapIn,
3535
chainCommands,
36-
exitCode,
3736
newlineInCode,
3837
createParagraphNear,
3938
liftEmptyBlock,
@@ -288,6 +287,9 @@ function tableInputRulePlugin() {
288287

289288
const table = buildTableNode(tableRange.headerCells, tableRange.aligns);
290289
const tr = state.tr.replaceWith(tableRange.from, tableRange.to, table);
290+
const afterTable = tr.mapping.map(tableRange.from) + table.nodeSize;
291+
tr.insert(afterTable, schema.node("paragraph"));
292+
tr.setSelection(TextSelection.near(tr.doc.resolve(afterTable + 1)));
291293
view.dispatch(tr);
292294
event.preventDefault();
293295
return true;
@@ -367,8 +369,12 @@ function tableCellEnterCommand(state, dispatch) {
367369

368370
function compactLineBreak(state, dispatch) {
369371
const { $head } = state.selection;
370-
if ($head.parent.type.name !== "paragraph") return false;
371-
if (dispatch) dispatch(state.tr.insertText("\n"));
372+
if ($head.parent.type.name === "code_block") {
373+
if (dispatch) dispatch(state.tr.insertText("\n"));
374+
return true;
375+
}
376+
if (!$head.parent.inlineContent) return false;
377+
if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView());
372378
return true;
373379
}
374380

@@ -431,6 +437,13 @@ function pastePlugin() {
431437
// ── Floating Table Toolbar ──
432438
function tableToolbarPlugin() {
433439
let toolbarEl = null;
440+
let blurHandler = null;
441+
let focusHandler = null;
442+
443+
function hideToolbar() {
444+
if (toolbarEl) toolbarEl.style.display = "none";
445+
}
446+
434447
function createToolbar() {
435448
const el = document.createElement("div");
436449
el.className = "table-toolbar";
@@ -452,6 +465,29 @@ function tableToolbarPlugin() {
452465
}
453466
const cmds = { addColumnBefore, addColumnAfter, deleteColumn,
454467
addRowBefore, addRowAfter, deleteRow, deleteTable };
468+
469+
function positionForSelection(view) {
470+
if (!view.hasFocus()) { hideToolbar(); return; }
471+
472+
const $h = view.state.selection.$head;
473+
let inTbl = false;
474+
let tblDom = null;
475+
for (let d = $h.depth; d > 0; d--) {
476+
if ($h.node(d).type.name === "table") {
477+
inTbl = true;
478+
tblDom = view.nodeDOM($h.before(d));
479+
break;
480+
}
481+
}
482+
483+
if (!inTbl || !tblDom) { hideToolbar(); return; }
484+
485+
const r = tblDom.getBoundingClientRect();
486+
toolbarEl.style.display = "flex";
487+
toolbarEl.style.left = r.left + "px";
488+
toolbarEl.style.top = (r.top - toolbarEl.offsetHeight - 6 + window.scrollY) + "px";
489+
}
490+
455491
return new Plugin({
456492
view(editorView) {
457493
toolbarEl = createToolbar();
@@ -460,29 +496,128 @@ function tableToolbarPlugin() {
460496
const btn = e.target.closest("button[data-cmd]");
461497
if (btn) { const c = cmds[btn.dataset.cmd]; if (c) c(editorView.state, editorView.dispatch); }
462498
});
499+
500+
blurHandler = () => hideToolbar();
501+
focusHandler = () => requestAnimationFrame(() => positionForSelection(editorView));
502+
editorView.dom.addEventListener("focusout", blurHandler);
503+
editorView.dom.addEventListener("focusin", focusHandler);
504+
463505
return {
464506
update(view) {
465-
const $h = view.state.selection.$head;
466-
let inTbl = false, tblDom = null;
467-
for (let d = $h.depth; d > 0; d--) {
468-
if ($h.node(d).type.name === "table") {
469-
inTbl = true;
470-
tblDom = view.nodeDOM($h.before(d));
471-
break;
472-
}
473-
}
474-
if (!inTbl || !tblDom) { toolbarEl.style.display = "none"; return; }
475-
const r = tblDom.getBoundingClientRect();
476-
toolbarEl.style.display = "flex";
477-
toolbarEl.style.left = r.left + "px";
478-
toolbarEl.style.top = (r.top - toolbarEl.offsetHeight - 6 + window.scrollY) + "px";
507+
positionForSelection(view);
479508
},
480-
destroy() { if (toolbarEl) toolbarEl.remove(); }
509+
destroy() {
510+
if (blurHandler) editorView.dom.removeEventListener("focusout", blurHandler);
511+
if (focusHandler) editorView.dom.removeEventListener("focusin", focusHandler);
512+
if (toolbarEl) toolbarEl.remove();
513+
}
481514
};
482515
}
483516
});
484517
}
485518

519+
// ── Floating Link Toolbar ──
520+
function linkToolbarPlugin() {
521+
let toolbarEl = null;
522+
let currentHref = "";
523+
let blurHandler = null;
524+
let focusHandler = null;
525+
526+
function hideToolbar() {
527+
if (toolbarEl) toolbarEl.style.display = "none";
528+
currentHref = "";
529+
}
530+
531+
function getActiveLinkHref(state) {
532+
const { selection } = state;
533+
const linkMarkType = schema.marks.link;
534+
if (!linkMarkType) return null;
535+
536+
if (selection.empty) {
537+
const { $from } = selection;
538+
const marks = $from.marks();
539+
const direct = marks.find((m) => m.type === linkMarkType);
540+
if (direct?.attrs?.href) return direct.attrs.href;
541+
542+
const before = $from.nodeBefore;
543+
if (before?.marks) {
544+
const m = before.marks.find((mark) => mark.type === linkMarkType);
545+
if (m?.attrs?.href) return m.attrs.href;
546+
}
547+
548+
const after = $from.nodeAfter;
549+
if (after?.marks) {
550+
const m = after.marks.find((mark) => mark.type === linkMarkType);
551+
if (m?.attrs?.href) return m.attrs.href;
552+
}
553+
554+
return null;
555+
}
556+
557+
let href = null;
558+
state.doc.nodesBetween(selection.from, selection.to, (node) => {
559+
if (!node.isText || !node.marks || href) return;
560+
const mark = node.marks.find((m) => m.type === linkMarkType);
561+
if (mark?.attrs?.href) href = mark.attrs.href;
562+
});
563+
return href;
564+
}
565+
566+
function createToolbar() {
567+
const el = document.createElement("div");
568+
el.className = "link-toolbar";
569+
el.innerHTML = `<button type="button" data-cmd="openLink">Open Link</button>`;
570+
el.style.display = "none";
571+
document.body.appendChild(el);
572+
el.addEventListener("mousedown", (event) => {
573+
event.preventDefault();
574+
if (!currentHref) return;
575+
window.open(currentHref, "_blank", "noopener,noreferrer");
576+
});
577+
return el;
578+
}
579+
580+
function positionForSelection(view) {
581+
if (!view.hasFocus()) {
582+
hideToolbar();
583+
return;
584+
}
585+
586+
const href = getActiveLinkHref(view.state);
587+
if (!href) {
588+
hideToolbar();
589+
return;
590+
}
591+
592+
currentHref = href;
593+
const coords = view.coordsAtPos(view.state.selection.from);
594+
toolbarEl.style.display = "flex";
595+
toolbarEl.style.left = coords.left + "px";
596+
toolbarEl.style.top = (coords.top - toolbarEl.offsetHeight - 6 + window.scrollY) + "px";
597+
}
598+
599+
return new Plugin({
600+
view(editorView) {
601+
toolbarEl = createToolbar();
602+
blurHandler = () => hideToolbar();
603+
focusHandler = () => requestAnimationFrame(() => positionForSelection(editorView));
604+
editorView.dom.addEventListener("focusout", blurHandler);
605+
editorView.dom.addEventListener("focusin", focusHandler);
606+
607+
return {
608+
update(view) {
609+
positionForSelection(view);
610+
},
611+
destroy() {
612+
if (blurHandler) editorView.dom.removeEventListener("focusout", blurHandler);
613+
if (focusHandler) editorView.dom.removeEventListener("focusin", focusHandler);
614+
if (toolbarEl) toolbarEl.remove();
615+
},
616+
};
617+
},
618+
});
619+
}
620+
486621
// ── Slash Command Menu ──
487622
const slashKey = new PluginKey("slashMenu");
488623
const SLASH_ITEMS = [
@@ -509,13 +644,16 @@ const SLASH_ITEMS = [
509644
if (!d) return true;
510645
const hdr = () => schema.nodes.table_header.createAndFill();
511646
const cel = () => schema.nodes.table_cell.createAndFill();
512-
d(s.tr.replaceSelectionWith(
513-
schema.nodes.table.create(null,[
514-
schema.nodes.table_row.create(null,[hdr(),hdr(),hdr()]),
515-
schema.nodes.table_row.create(null,[cel(),cel(),cel()]),
516-
schema.nodes.table_row.create(null,[cel(),cel(),cel()]),
517-
])
518-
));
647+
const table = schema.nodes.table.create(null,[
648+
schema.nodes.table_row.create(null,[hdr(),hdr(),hdr()]),
649+
schema.nodes.table_row.create(null,[cel(),cel(),cel()]),
650+
schema.nodes.table_row.create(null,[cel(),cel(),cel()]),
651+
]);
652+
const tr = s.tr.replaceSelectionWith(table);
653+
const afterTable = tr.selection.from;
654+
tr.insert(afterTable, schema.node("paragraph"));
655+
tr.setSelection(TextSelection.near(tr.doc.resolve(afterTable + 1)));
656+
d(tr.scrollIntoView());
519657
return true;
520658
} },
521659
];
@@ -547,6 +685,8 @@ function slashMenuPlugin() {
547685
menuEl.querySelectorAll(".slash-menu-item").forEach(el => {
548686
el.addEventListener("mousedown", e => { e.preventDefault(); execute(view, items[+el.dataset.i]); });
549687
});
688+
const activeEl = menuEl.querySelector(".slash-menu-item.active");
689+
if (activeEl) activeEl.scrollIntoView({ block: "nearest" });
550690
}
551691
function execute(view, item) {
552692
view.dispatch(view.state.tr.delete(slashPos, view.state.selection.from));
@@ -627,7 +767,8 @@ function createPlugins() {
627767
"Mod-z": undo,
628768
"Shift-Mod-z": redo,
629769
"Mod-y": redo,
630-
"Mod-Enter": chainCommands(exitCode, compactLineBreak),
770+
"Mod-Enter": compactLineBreak,
771+
"Shift-Enter": compactLineBreak,
631772
Enter: chainCommands(
632773
newlineInCode,
633774
tableCellEnterCommand,
@@ -646,6 +787,7 @@ function createPlugins() {
646787
tableEditing(),
647788
keymap({ Tab: goToNextCell(1), "Shift-Tab": goToNextCell(-1) }),
648789
tableToolbarPlugin(),
790+
linkToolbarPlugin(),
649791
pastePlugin(),
650792
];
651793
}

0 commit comments

Comments
 (0)