Skip to content

Commit c6002bb

Browse files
committed
allow changing text tag (p, h1, h2, etc...)
1 parent 4161f23 commit c6002bb

File tree

4 files changed

+241
-7
lines changed

4 files changed

+241
-7
lines changed

lib/Yancy/Editor/src/content-editor.svelte

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717
content: string;
1818
};
1919
};
20+
type YancyElement = {
21+
tag: string;
22+
class: string;
23+
style: string;
24+
};
25+
type YancyFocusMessage = YancyIframeMessage & {
26+
name: "focus";
27+
stack: YancyElement[];
28+
};
2029
2130
let saving: boolean = false;
2231
const saveBlock = async (msg: YancyInputMessage) => {
@@ -59,6 +68,21 @@
5968
saving = true;
6069
const inputEvent = e.data as YancyInputMessage;
6170
handleSaveBlock(inputEvent);
71+
} else if (e.data.name === "focus") {
72+
const focusEvent = e.data as YancyFocusMessage;
73+
74+
// Decide which toolbars to enable
75+
const textTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6"];
76+
const textContainers = [...textTags, "y-block"];
77+
if (textContainers.includes(focusEvent.stack[0].tag)) {
78+
enableTextToolbar = true;
79+
const tagStackEntry = focusEvent.stack.find((s) =>
80+
textTags.includes(s.tag),
81+
);
82+
currentTextTag = tagStackEntry?.tag || "p";
83+
}
84+
} else if (e.data.name === "blur") {
85+
enableTextToolbar = false;
6286
}
6387
};
6488
@@ -71,10 +95,37 @@
7195
e.target.contentWindow.postMessage("Yancy.init", "*", [channel.port2]);
7296
}
7397
};
98+
99+
let enableTextToolbar: boolean = false;
100+
let currentTextTag: string = "p";
101+
function updateTextTag(newStyle: string) {
102+
console.log("updating text style to " + newStyle);
103+
channel.port1.postMessage({
104+
name: "style",
105+
tag: newStyle,
106+
});
107+
currentTextTag = newStyle;
108+
}
74109
</script>
75110

76111
<div class="editor-view">
77112
<div class="toolbar">
113+
<div class="text">
114+
<select
115+
name="tag"
116+
bind:value={() => currentTextTag, updateTextTag}
117+
disabled={!enableTextToolbar}
118+
>
119+
<!-- XXX: Should be a popup to show what style looks like -->
120+
<option value="p">Normal</option>
121+
<option value="h1">Heading 1</option>
122+
<option value="h2">Heading 2</option>
123+
<option value="h3">Heading 3</option>
124+
<option value="h4">Heading 4</option>
125+
<option value="h5">Heading 5</option>
126+
<option value="h6">Heading 6</option>
127+
</select>
128+
</div>
78129
<div class="status">
79130
{#if saving}
80131
<span class="spin" title="Saving"><MdiLoading /></span>

lib/Yancy/Editor/src/iframe.ts

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,36 @@ type YancyUpdateMessage = YancyEditorMessage & {
1414
content: string;
1515
};
1616
};
17+
type YancyStyleMessage = YancyEditorMessage & {
18+
name: "style";
19+
tag?: string;
20+
class?: string;
21+
style?: string;
22+
};
1723

18-
function handleBlockInput(e: InputEvent) {
24+
function handleBlockClick(e: PointerEvent) {
1925
if (!(e.target instanceof HTMLElement)) {
2026
return;
2127
}
22-
console.debug("block input event", e);
23-
const blockEl = e.target;
28+
console.debug("block click event", e);
29+
const msg = {
30+
name: "focus",
31+
stack: [],
32+
};
33+
let el = e.target;
34+
while (el) {
35+
msg.stack.push({
36+
tag: el.tagName.toLowerCase(),
37+
class: el.className,
38+
style: el.style.cssText,
39+
});
40+
el = el.parentElement;
41+
}
42+
Yancy.editorPort.postMessage(msg);
43+
e.stopPropagation();
44+
}
45+
46+
function sendInputMessage(blockEl: HTMLElement) {
2447
const blockData = {
2548
block_id: blockEl.getAttribute("block_id"),
2649
name: blockEl.getAttribute("name"),
@@ -33,13 +56,36 @@ function handleBlockInput(e: InputEvent) {
3356
});
3457
}
3558

59+
function handleBlockInput(e: InputEvent) {
60+
if (!(e.target instanceof HTMLElement)) {
61+
return;
62+
}
63+
console.debug("block input event", e);
64+
sendInputMessage(e.target);
65+
}
66+
67+
function handleBodyClick(e: PointerEvent) {
68+
// We can click on the window without technically removing
69+
// focus from one of the contenteditable elements, so make
70+
// sure we don't still have a good focus...
71+
if (document.activeElement.closest("[contenteditable]")) {
72+
return;
73+
}
74+
const msg = {
75+
name: "blur",
76+
};
77+
Yancy.editorPort.postMessage(msg);
78+
}
79+
3680
function handleEvent(e: MessageEvent<YancyEditorMessage>) {
3781
console.debug("got message from editor", e);
3882
if (e.data.name === "enable") {
83+
window.addEventListener("click", handleBodyClick);
3984
for (const block of Array.from(document.querySelectorAll("y-block"))) {
4085
if (block instanceof HTMLElement) {
4186
block.contentEditable = "true";
4287
block.addEventListener("input", handleBlockInput);
88+
block.addEventListener("click", handleBlockClick);
4389
}
4490
}
4591
} else if (e.data.name === "update") {
@@ -51,7 +97,71 @@ function handleEvent(e: MessageEvent<YancyEditorMessage>) {
5197
`y-block[name=${updateEvent.block.name}]`,
5298
);
5399
block.setAttribute("block_id", "" + updateEvent.block.block_id);
100+
block.innerHTML = updateEvent.block.content;
101+
}
102+
} else if (e.data.name === "style") {
103+
const styleEvent = e.data as YancyStyleMessage;
104+
const sel = getSelection();
105+
console.log("selection at start", sel);
106+
if (!sel) {
107+
console.error("Cannot update style: No selection");
108+
return;
109+
}
110+
console.debug("changing style", styleEvent);
111+
// If we're in a bare text node (parent node is not a text style),
112+
// pretend we're in a <p> node that surrounds all the text
113+
// we can reach from where we are without crossing another
114+
// element node.
115+
const anchorNode = sel.anchorNode;
116+
const inText = !(anchorNode instanceof HTMLElement);
117+
// Just gotta change the tag name of our parent element?
118+
const oldParent = inText ? anchorNode.parentElement : anchorNode;
119+
const blockEl = oldParent.closest("y-block") as HTMLElement;
120+
const textStyles = ["p", "h1", "h2", "h3", "h4", "h5", "h6"];
121+
if (!textStyles.includes(oldParent.tagName.toLowerCase())) {
122+
console.log("adding wrapper around current text...");
123+
const textNodes = [anchorNode];
124+
let testNode = anchorNode.previousSibling;
125+
while (testNode && testNode.nodeType !== Node.ELEMENT_NODE) {
126+
textNodes.unshift(testNode);
127+
testNode = testNode.previousSibling;
128+
}
129+
testNode = anchorNode.nextSibling;
130+
while (testNode && testNode.nodeType !== Node.ELEMENT_NODE) {
131+
textNodes.push(testNode);
132+
testNode = testNode.nextSibling;
133+
}
134+
const parentNode = anchorNode.parentNode;
135+
const newParent = document.createElement(styleEvent.tag);
136+
console.log(
137+
"Wrapping text nodes",
138+
textNodes,
139+
"parent",
140+
parentNode,
141+
"nextSibling",
142+
testNode,
143+
);
144+
newParent.append(...textNodes);
145+
parentNode.insertBefore(newParent, testNode);
146+
147+
// Need to fix the selection now that we've changed everything
148+
sel.setPosition(
149+
inText ? newParent.childNodes[0] : newParent,
150+
sel.anchorOffset,
151+
);
152+
} else {
153+
const newParent = document.createElement(styleEvent.tag);
154+
newParent.append(...Array.from(oldParent.childNodes));
155+
oldParent.replaceWith(newParent);
156+
157+
// Need to fix the selection now that we've changed everything
158+
sel.setPosition(
159+
inText ? newParent.childNodes[0] : newParent,
160+
sel.anchorOffset,
161+
);
54162
}
163+
console.log("selection at end", sel);
164+
sendInputMessage(blockEl);
55165
}
56166
}
57167

myapp.pl

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,13 @@
9797
@@ index.html.ep
9898
% layout 'default';
9999
%= block landing => {}, begin
100-
This is the default landing page content.
100+
This is just sitting here because...
101+
<p>This is the default landing page content.</p>
102+
This is just sitting here because...
103+
<h1>This is an H1</h1>
104+
This is just sitting here because...
101105
% end
106+
<p>This is outside the y-block</p>
102107
103108
@@ multi-blocks.html.ep
104109
% layout 'default';

t/inline_editor.spec.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@ class EditorPage {
1818
contentTabPanel: Locator;
1919
contentFrame: Locator;
2020
contentDocument: FrameLocator;
21+
22+
// Content Editor Toolbar
23+
textTagSelect: Locator;
2124
statusIcon: Locator;
25+
2226
constructor(page: Page) {
2327
this.contentTabLabel = page.getByRole("button", { name: "Content" });
2428
this.contentTabPanel = page.getByRole("region", { name: "Content" });
2529
this.contentFrame = page.locator("#content-view");
2630
this.contentDocument = page.frameLocator("#content-view");
2731
this.statusIcon = page.locator(".status");
32+
this.textTagSelect = page.locator(".toolbar .text select[name=tag]");
2833
}
2934
}
3035

@@ -87,11 +92,12 @@ test.describe("inline content editor", () => {
8792
await block.fill("New landing page content");
8893
await expect(editor.statusIcon.getByTitle("Saved")).toBeVisible();
8994

90-
const apiBlocks = await request.get(
95+
const res = await request.get(
9196
"/yancy/api/blocks?name=landing&path=" + encodeURIComponent("/"),
9297
);
93-
expect(apiBlocks.ok()).toBeTruthy();
94-
expect(await apiBlocks.json()).toEqual(
98+
expect(res.ok()).toBeTruthy();
99+
const apiBlocks = await res.json();
100+
expect(apiBlocks).toEqual(
95101
expect.objectContaining({
96102
items: expect.arrayContaining([
97103
expect.objectContaining({
@@ -102,6 +108,68 @@ test.describe("inline content editor", () => {
102108
]),
103109
}),
104110
);
111+
await request.delete(`/yancy/api/blocks/${apiBlocks.items[0].block_id}`);
112+
});
113+
114+
test.describe("editor toolbar", () => {
115+
test.beforeEach(async ({ page }) => {
116+
await page.goto("/yancy");
117+
const editor = new EditorPage(page);
118+
await editor.contentTabLabel.click();
119+
await editor.contentTabPanel.getByText("index").click();
120+
});
121+
122+
test("clicking in content document updates text toolbar", async ({
123+
page,
124+
}) => {
125+
const editor = new EditorPage(page);
126+
const landingBlock = editor.contentDocument.locator(
127+
"y-block[name=landing]",
128+
);
129+
await landingBlock.click();
130+
expect(editor.textTagSelect).toBeVisible();
131+
expect(editor.textTagSelect).toBeEnabled();
132+
await expect(editor.textTagSelect).toHaveValue("p");
133+
134+
await landingBlock.locator("h1").click();
135+
expect(editor.textTagSelect).toBeEnabled();
136+
await expect(editor.textTagSelect).toHaveValue("h1");
137+
138+
// XXX: Need slight delay to allow parent editor to be updated
139+
await editor.contentDocument.getByText("outside").click({ delay: 50 });
140+
expect(editor.textTagSelect).toBeDisabled();
141+
});
142+
143+
test("modify block text style", async ({ page, request }) => {
144+
const editor = new EditorPage(page);
145+
const landingBlock = editor.contentDocument.locator(
146+
"y-block[name=landing]",
147+
);
148+
await landingBlock.locator("h1").click();
149+
await editor.textTagSelect.selectOption("h2");
150+
await expect(landingBlock.locator("h2")).toBeVisible();
151+
152+
await expect(editor.statusIcon.getByTitle("Saved")).toBeVisible();
153+
const res = await request.get(
154+
"/yancy/api/blocks?name=landing&path=" + encodeURIComponent("/"),
155+
);
156+
expect(res.ok()).toBeTruthy();
157+
const apiBlocks = await res.json();
158+
expect(apiBlocks).toEqual(
159+
expect.objectContaining({
160+
items: expect.arrayContaining([
161+
expect.objectContaining({
162+
name: "landing",
163+
path: "/",
164+
content: expect.stringMatching("<h2>This is an H1</h2>"),
165+
}),
166+
]),
167+
}),
168+
);
169+
await request.delete(
170+
`/yancy/api/blocks/${apiBlocks.items[0].block_id}`,
171+
);
172+
});
105173
});
106174
});
107175

0 commit comments

Comments
 (0)