Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
40e5dc8
feat: first attempt at adding prosemirror-tables
3mcd Apr 28, 2025
9bb0a37
feat: add columnResizing plugin
3mcd Apr 28, 2025
563eb64
chore: more table work
3mcd Apr 28, 2025
2b7e8db
feat: make tables prettier
3mcd Apr 29, 2025
1e08ded
feat: remove alternating background
3mcd Apr 29, 2025
9176811
chore: bump prosemirror view
3mcd Apr 29, 2025
3e9aa0a
feat: get tabbing working
3mcd Apr 29, 2025
dd0047c
feat: add figure
3mcd Apr 30, 2025
6ef665c
feat: make it look a lil more like allison's
3mcd May 1, 2025
47cb231
chore: downgrade prosemirror-view
3mcd May 1, 2025
7bcb93d
feat: toggle function
3mcd May 1, 2025
84c23e7
keep panel open when title/caption toggled
3mcd May 7, 2025
ce15539
get marks working
3mcd May 8, 2025
2282864
simplify
3mcd May 8, 2025
a099800
simplify
3mcd May 8, 2025
d877004
css
3mcd May 12, 2025
16a01c4
fix up media upload
3mcd May 12, 2025
2036d80
credit and license
3mcd May 12, 2025
7dfdd95
fix type error
3mcd May 12, 2025
a7aa50e
remove unused code
3mcd May 12, 2025
2264f50
remove more unused code
3mcd May 12, 2025
0e9d6ed
dont open by default
3mcd May 12, 2025
26a882b
Merge branch 'main' into em/pm-tables
tefkah May 13, 2025
d73ab8d
fix: fix some node playwright tests
tefkah May 13, 2025
3c19080
fix: make the media playwright tests fail later
tefkah May 13, 2025
74dec8d
a little cleanup
3mcd May 13, 2025
276b3ce
restore closing behavior
3mcd May 13, 2025
b29573b
fix: fix prosemirror version mismatches (#1242)
tefkah May 13, 2025
4ba397a
fix type error
3mcd May 13, 2025
8f87963
more cleanup
3mcd May 13, 2025
920d713
typerror
3mcd May 13, 2025
c0367d1
fix toggle test
3mcd May 13, 2025
13f556e
table resizing works now yay
3mcd May 13, 2025
98bcb8f
fix key issue
3mcd May 13, 2025
f924ea0
try testid
3mcd May 13, 2025
a55d1e6
try blur
3mcd May 13, 2025
22397ae
open advanced
3mcd May 13, 2025
1517acc
expand advanced options
3mcd May 13, 2025
78ed9bc
fix mark attr updates
3mcd May 13, 2025
9965669
resolve closest node to cursor for attributepanel
3mcd May 13, 2025
2475322
try this
3mcd May 14, 2025
c0d7dbf
force test run
3mcd May 14, 2025
1a390e1
active node is node after from and node before to
3mcd May 14, 2025
c233f6d
try state.selection. instead of anchor
3mcd May 14, 2025
4d6f9b4
fix a couple more tests
3mcd May 14, 2025
33ce227
try this
3mcd May 14, 2025
53c76a3
try doubleclick
3mcd May 14, 2025
5e47918
dblclick
3mcd May 14, 2025
a5156ca
try dblclick again
3mcd May 14, 2025
b610712
nullish form control
3mcd May 14, 2025
a414089
redundant click
3mcd May 14, 2025
ef96d1f
remove id and class from MediaUpload menu
3mcd May 14, 2025
627be67
add form
3mcd May 14, 2025
274a956
Update packages/context-editor/src/style.css
3mcd May 15, 2025
992a054
Update packages/context-editor/src/style.css
3mcd May 15, 2025
c93c1c8
Update packages/context-editor/src/style.css
3mcd May 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
"oslo": "^1.2.1",
"pg": "^8.11.3",
"prosemirror-markdown": "^1.12.0",
"prosemirror-model": "^1.24.1",
"prosemirror-model": "catalog:",
"qs": "^6.14.0",
"react": "catalog:react19",
"react-dom": "catalog:react19",
Expand Down
8 changes: 5 additions & 3 deletions packages/context-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,19 @@
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-model": "catalog:",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-state": "catalog:",
"prosemirror-suggest": "^3.0.0",
"prosemirror-tables": "^1.7.1",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this version of prosemirror tables uses a different version of prosemirror-model then we have been using. this causes errors when eg using math inside of a table.

i fixed this here: #1242

"prosemirror-transform": "^1.10.0",
"prosemirror-view": "1.37.1",
"prosemirror-view": "catalog:",
"react": "catalog:react19",
"react-csv-to-table": "^0.0.4",
"react-dom": "catalog:react19",
"react-hook-form": "catalog:",
"react-reconciler": "catalog:react19",
"schemas": "workspace:*",
"ui": "workspace:*",
"utils": "workspace:*",
Expand Down
1 change: 0 additions & 1 deletion packages/context-editor/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export default defineConfig({
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL,

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
Expand Down
56 changes: 36 additions & 20 deletions packages/context-editor/playwright/attribute-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ test.describe("attribute panel", () => {
await test.step("fill out panel while keeping it open", async () => {
const id = "test id";
const className = "test class";
await page.getByRole("textbox", { name: "id" }).fill(id);
await page.getByRole("textbox", { name: "class" }).fill(className);
await page.getByTestId("advanced-options-trigger").click();
await page.getByTestId("id-input").fill(id);
await page.getByTestId("class-input").fill(className);
});

await test.step("can close the panel by clicking the button again", async () => {
Expand Down Expand Up @@ -58,11 +59,17 @@ test.describe("attribute panel", () => {
await page.getByRole("button", { name: "Bold" }).click();
await page.locator(".ProseMirror").pressSequentially(text);
await clickNode(page, "paragraph");
await page.getByTestId("attribute-panel").waitFor();
await expect(page.getByTestId("attribute-panel")).not.toContainText("strong");
const panel = page.getByTestId("attribute-panel");
await panel.waitFor();
await expect(panel).not.toContainText("strong");

await page.getByTestId("advanced-options-trigger").click();
const id = "paragraph-id";
await page.getByRole("textbox", { name: "id" }).fill(id);
await expect(page.getByRole("textbox", { name: "id" })).toHaveValue(id);
const idInput = page.getByTestId("id-input");
await idInput.fill(id);
await expect(idInput).toHaveValue(id);
// click on the panel to blur the id input
await panel.click();
});

await test.step("click on other text", async () => {
Expand All @@ -72,7 +79,8 @@ test.describe("attribute panel", () => {
.getByText(text)
.click({ position: { x: 20, y: 0 } });
await expect(page.getByTestId("attribute-panel")).toContainText("strong");
await expect(page.getByRole("textbox", { name: "id" })).toHaveValue("");
await page.getByTestId("advanced-options-trigger").click();
await expect(page.getByTestId("id-input")).toHaveValue("");
});
});

Expand All @@ -91,15 +99,17 @@ test.describe("attribute panel", () => {
await page.getByTestId("attribute-panel").waitFor();
const id = "id1";
const className = "class1";
await page.getByRole("textbox", { name: "id" }).fill(id);
await page.getByRole("textbox", { name: "class" }).fill(className);
await page.getByTestId("advanced-options-trigger").click();
await page.getByTestId("id-input").fill(id);
await page.getByTestId("class-input").fill(className);
});

await test.step("open panel on second paragraph", async () => {
await clickNode(page, "paragraph", 1);
// Make sure the second paragraph does not have the same attrs as the first
await expect(page.getByRole("textbox", { name: "id" })).toHaveValue("");
await expect(page.getByRole("textbox", { name: "class" })).toHaveValue("");
await page.getByTestId("advanced-options-trigger").click();
await expect(page.getByTestId("id-input")).toHaveValue("");
await expect(page.getByTestId("class-input")).toHaveValue("");
});
});
});
Expand Down Expand Up @@ -165,24 +175,28 @@ test.describe("attribute panel", () => {
await test.step("add attrs to italic", async () => {
await editor.press("ArrowLeft");
await page.getByTestId("attribute-panel").waitFor();
await page.getByRole("textbox", { name: "id" }).fill("italic-id");
await page.getByTestId("advanced-options-trigger").click();
await page.getByTestId("id-input").fill("italic-id");
});

await test.step("add attrs to bold", async () => {
await editor.getByText("bold").click({ position: { x: 20, y: 0 } });
await editor.getByText("bold").dblclick({ position: { x: 20, y: 0 } });
await page.getByTestId("attribute-panel").waitFor();
await page.getByRole("textbox", { name: "id" }).fill("bold-id");
await page.getByTestId("advanced-options-trigger").click();
await page.getByTestId("id-input").fill("bold-id");
});

await test.step("click between marks", async () => {
const clickOptions =
browserName === "chromium" ? { position: { x: 20, y: 0 } } : undefined;
await editor.getByText("italic").click(clickOptions);
await editor.getByText("italic").dblclick(clickOptions);
await page.getByTestId("attribute-panel").getByText("em").waitFor();
await expect(page.getByRole("textbox", { name: "id" })).toHaveValue("italic-id");
await editor.getByText("bold").click(clickOptions);
await page.getByTestId("advanced-options-trigger").click();
await expect(page.getByTestId("id-input")).toHaveValue("italic-id");
await editor.getByText("bold").dblclick(clickOptions);
await page.getByTestId("attribute-panel").getByText("strong").waitFor();
await expect(page.getByRole("textbox", { name: "id" })).toHaveValue("bold-id");
await page.getByTestId("advanced-options-trigger").click();
await expect(page.getByTestId("id-input")).toHaveValue("bold-id");
});
});

Expand All @@ -202,13 +216,15 @@ test.describe("attribute panel", () => {
await test.step("add attrs to second", async () => {
await editor.press("ArrowLeft");
await page.getByTestId("attribute-panel").waitFor();
await page.getByRole("textbox", { name: "id" }).fill("second-id");
await page.getByTestId("advanced-options-trigger").click();
await page.getByTestId("id-input").fill("second-id");
});

await test.step("make sure first does not have the same attr", async () => {
await editor.getByText("first").click({ position: { x: 20, y: 0 } });
await page.getByTestId("attribute-panel").waitFor();
await expect(page.getByRole("textbox", { name: "id" })).toHaveValue("");
await page.getByTestId("advanced-options-trigger").click();
await expect(page.getByTestId("id-input")).toHaveValue("");
});
});
});
Expand Down
1 change: 1 addition & 0 deletions packages/context-editor/playwright/marks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ test.describe("link", () => {
const className = "className";
await addLinkAndOpenMenu(page);
const panel = page.getByTestId("attribute-panel");
await panel.waitFor({ state: "visible" });
await panel.getByRole("textbox", { name: "URL" }).fill(url);
await panel.getByRole("switch", { name: "Open in new tab" }).click();
await panel.getByTestId("advanced-options-trigger").click();
Expand Down
10 changes: 5 additions & 5 deletions packages/context-editor/playwright/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ import { EDITOR_WITH_IMAGE_STORY } from "./constants";
import { getProsemirrorState } from "./utils";

const clickImageNode = async (page: Page) => {
// Have to click twice in playwright for some reason
await page.getByRole("button", { name: "image", exact: true }).click();
await page.getByRole("button", { name: "image", exact: true }).click();
};

test.describe("images", () => {
test.beforeEach(async ({ page }) => {
await page.goto(EDITOR_WITH_IMAGE_STORY);
const editor = page.locator(".ProseMirror");
await editor.click();
// const editor = page.locator(".ProseMirror");
// await editor.click();
});

test("can set image attributes", async ({ page }) => {
Expand All @@ -31,6 +29,7 @@ test.describe("images", () => {
align: "right",
fullResolution: true,
};

await page.getByRole("textbox", { name: "Source" }).fill(expected.src);
await page.getByRole("textbox", { name: "Alt text" }).fill(expected.alt);
await page.getByRole("textbox", { name: "Link to" }).fill(expected.linkTo);
Expand All @@ -48,7 +47,8 @@ test.describe("images", () => {
});

test("can add and remove caption, credit, license fields", async ({ page }) => {
await clickImageNode(page);
// await clickImageNode(page);
await page.getByRole("button", { name: "figure", exact: true }).first().click();
await page.getByTestId("attribute-panel").waitFor();
await expect(page.getByRole("button", { name: "figcaption" })).toHaveCount(0);
const figureParts = [
Expand Down
2 changes: 1 addition & 1 deletion packages/context-editor/playwright/suggest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ test.describe("atom renderer", () => {
await test.step("set image", async () => {
const imageName = "test image";
await page.getByTestId("attribute-panel").waitFor();
await page.getByText("Attribute").waitFor();
await page.getByText("Atom Data").waitFor();
await page.getByRole("textbox", { name: "rd:source" }).fill("/image1.jpeg");
await page.getByRole("textbox", { name: "rd:alt" }).fill(imageName);
await page.locator(".ProseMirror").getByRole("img", { name: imageName }).waitFor();
Expand Down
44 changes: 24 additions & 20 deletions packages/context-editor/src/ContextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ import { AttributePanel } from "./components/AttributePanel";
import { basePlugins } from "./plugins";
import { baseSchema } from "./schemas";

import "prosemirror-view/style/prosemirror.css";
import "prosemirror-gapcursor/style/gapcursor.css";
import "prosemirror-view/style/prosemirror.css";
// For math
import "@benrbray/prosemirror-math/dist/prosemirror-math.css";
import "katex/dist/katex.min.css";

import { fixTables } from "prosemirror-tables";

import { cn } from "utils";

import { EditorContextProvider } from "./components/Context";
import { MenuBar } from "./components/MenuBar";
import SuggestPanel from "./components/SuggestPanel";

Expand Down Expand Up @@ -61,13 +62,18 @@ const initSuggestProps: SuggestProps = {

export default function ContextEditor(props: ContextEditorProps) {
const [suggestData, setSuggestData] = useState<SuggestProps>(initSuggestProps);
const [editorState, setEditorState] = useState(
EditorState.create({
const [editorState, setEditorState] = useState(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL you can use a function to initialize useState... I feel like i should have known that haha

let state = EditorState.create({
doc: props.initialDoc ? baseSchema.nodeFromJSON(props.initialDoc) : undefined,
schema: baseSchema,
plugins: [...basePlugins(baseSchema, props, suggestData, setSuggestData), reactKeys()],
})
);
});
const fix = fixTables(state);
if (fix) {
state = state.apply(fix.setMeta("addToHistory", false));
}
return state;
});

const nodeViews = useMemo(() => {
return { contextAtom: props.atomRenderingComponent };
Expand Down Expand Up @@ -97,20 +103,18 @@ export default function ContextEditor(props: ContextEditorProps) {
editable={() => !props.disabled}
className={cn("font-serif", props.className)}
>
<EditorContextProvider activeNode={null} position={0}>
{props.hideMenu ? null : (
<div className="sticky top-0 z-10">
<MenuBar upload={props.upload} />
</div>
)}
<ProseMirrorDoc />
<AttributePanel menuHidden={!!props.hideMenu} containerRef={containerRef} />
<SuggestPanel
suggestData={suggestData}
setSuggestData={setSuggestData}
containerRef={containerRef}
/>
</EditorContextProvider>
{props.hideMenu ? null : (
<div className="sticky top-0 z-10">
<MenuBar upload={props.upload} />
</div>
)}
<ProseMirrorDoc />
<AttributePanel menuHidden={!!props.hideMenu} containerRef={containerRef} />
<SuggestPanel
suggestData={suggestData}
setSuggestData={setSuggestData}
containerRef={containerRef}
/>
</ProseMirror>
</div>
);
Expand Down
74 changes: 74 additions & 0 deletions packages/context-editor/src/commands/figures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { EditorState } from "prosemirror-state";

import { Fragment, Node } from "prosemirror-model";
import { NodeSelection } from "prosemirror-state";

import { assert } from "utils";

import type { Dispatch } from "./types";

const nodeTypes = ["title", "figcaption", "credit", "license"] as const;
const nodeSlots = [
"title",
// (child)
"table",
"image",
// (/child)
"figcaption",
"credit",
"license",
] as const;
type ToggleableNodeType = (typeof nodeTypes)[number];

const errNodeNotResolved = "Node not resolved at position";
const errNodeNotFigure = "Node is not a figure node";
const errNodeContentInvalid = "Node has invalid content";

export const toggleFigureNode =
(state: EditorState, dispatch?: Dispatch) =>
(position: number, nodeType: ToggleableNodeType) => {
const figurePosition = state.doc.resolve(position);
const figureNode = figurePosition.nodeAfter;
assert(figureNode !== null, errNodeNotResolved);
assert(figureNode.type.name === "figure", errNodeNotFigure);
const sparseContent: (Node | undefined)[] = [];

@3mcd 3mcd May 12, 2025

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use a sparse array here to ensure each element is in the proper order. First I fill in the array like

[title, image]
[, table, figcaption]
[, image, , , credit]
// etc

Then remove all the holes in the array.

This could be done by pushing each existing node into a new array, but if the figure's children ever got out of sync with the figure node's content expression for some reason, this approach will automatically correct the order of child elements. i.e. it does not rely on the document state being correct to produce a valid next document


let insert = true;
figureNode.content.forEach((node) => {
if (node.type.name !== nodeType) {
const slot = nodeSlots.findIndex((name) => name === node.type.name);
assert(slot > -1, errNodeContentInvalid);
sparseContent[slot] = node;
} else {
insert = false;
}
});

if (insert) {
const slot = nodeSlots.findIndex((name) => name === nodeType);
sparseContent[slot] = figureNode.type.schema.nodes[nodeType].create();
}

// remove holes in the array
const content = sparseContent.filter((node) => node !== undefined) as Node[];

// replace the figure node with the new content
const newFigureNode = figureNode.type.create(
figureNode.attrs,
Fragment.fromArray(content),
figureNode.marks
);

if (dispatch) {
const tr = state.tr.replaceWith(
figurePosition.pos,
figurePosition.pos + figureNode.nodeSize,
newFigureNode
);
const selection = NodeSelection.create(tr.doc, tr.mapping.map(figurePosition.pos));
tr.setSelection(selection);
dispatch(tr);
}

return true;
};
Loading
Loading