diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..227a2f3de
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,35 @@
+# pie-lib
+
+## What this is
+
+A monorepo of shared React components/utilities for the **PIE (Portable Interactions & Elements)** assessment framework — math rendering/input, drag-and-drop, charting, rich-text editing, config UI, rubric/scoring UI, icons, style utils. Published under the `@pie-lib/*` npm scope.
+
+Consumed by the sibling repo **`pie-elements`** (`../pie-elements`), which installs these as normal npm dependencies (not a local link) — `pie-elements` pins exact versions in its root `package.json` `resolutions` block.
+
+## Repo structure
+
+- Lerna (independent versioning, conventional-commits driven) + Yarn workspaces (`packages/*`).
+- `packages/` — 28 library packages + `demo` (a Next.js app for local preview of all packages, deployed to now.sh on `develop`/`master` merges).
+- Every package: `src/` (with `__tests__/`) → `lib/` (compiled output, checked in).
+- No TypeScript anywhere — plain JS/JSX with PropTypes.
+
+Notable packages: `render-ui` (most widely consumed — preview layout, feedback, collapsible, response indicators), `drag` (dnd-kit based), `math-input`/`math-rendering`/`math-toolbar` (MathQuill, mid-migration to MathLive — see `docs/mathquill-to-mathlive-migration.md`), `charting`/`plot` (visx), `config-ui`, `controller-utils`, `test-utils` (shared test helpers/mocks).
+
+## Commands
+
+- `npm run build` — build all packages
+- `npm test` — run all tests; to test a single package: `./node_modules/.bin/jest packages/pkg-name/src/`
+- `npm run lint` — ESLint
+- `scripts/dev --scope $package-name` — run the demo site on localhost:3000 (`--scope` optional, defaults to all)
+- `npm run release` — release + deploy (merging to `develop` → `next` dist-tag / pie-lib-next.now.sh; merging to `master` → `latest` / pie-lib.now.sh)
+
+## Conventions
+
+- **Conventional commits syntax** on commit messages — Lerna uses this to detect the appropriate independent version bump per package.
+- Styling has migrated to MUI v7 + Emotion (older packages may still show JSS-era patterns).
+- If test setup gets out of sync: `npm run build`, `rm -fr packages/test-utils/node_modules`, then retry.
+- Node >=18 required; there's a known Jest/Node quirk documented in `.cursor/skills/nvm-jest-v22/SKILL.md` (use `nvm use v22` before running jest directly if you hit a syntax error).
+
+## Working preferences
+
+- **Do not create git commits unless explicitly asked.** The user commits their own changes — leave the working tree staged/unstaged as appropriate and let them review and commit themselves.
diff --git a/packages/editable-html-tip-tap/src/components/EditableHtml.jsx b/packages/editable-html-tip-tap/src/components/EditableHtml.jsx
index b6d93144e..13f764c9f 100644
--- a/packages/editable-html-tip-tap/src/components/EditableHtml.jsx
+++ b/packages/editable-html-tip-tap/src/components/EditableHtml.jsx
@@ -243,7 +243,7 @@ export const EditableHtml = (props) => {
let cb;
if (scheduled && result) {
- // finish editing only on success
+ // finish editing only on success
cb = props.onChange;
}
@@ -424,10 +424,12 @@ const StyledEditorContent = styled(EditorContent, {
},
// Out of flow so the caret stays at the start of the block; in-flow ::before pushes the caret after the hint text.
- '& p.is-editor-empty, & div.is-editor-empty': {
+ // :only-child ensures the placeholder is hidden whenever the editor has other content (images, upload nodes, etc.)
+ // and covers the type+backspace edge case where Tiptap only adds is-empty (not is-editor-empty).
+ '& p[data-placeholder].is-empty:only-child, & div[data-placeholder].is-empty:only-child': {
position: 'relative',
},
- '& p.is-editor-empty::before, & div.is-editor-empty::before': {
+ '& p[data-placeholder].is-empty:only-child::before, & div[data-placeholder].is-empty:only-child::before': {
content: 'attr(data-placeholder)',
position: 'absolute',
left: 0,
diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/image-component.test.jsx b/packages/editable-html-tip-tap/src/extensions/__tests__/image-component.test.jsx
index 84216ceee..6ad818db1 100644
--- a/packages/editable-html-tip-tap/src/extensions/__tests__/image-component.test.jsx
+++ b/packages/editable-html-tip-tap/src/extensions/__tests__/image-component.test.jsx
@@ -39,6 +39,7 @@ describe('ImageComponent', () => {
const createMockEditor = (selection = { from: 0, to: 1 }) => ({
_tiptapContainerEl: document.body,
+ isEditable: true,
commands: {
updateAttributes: jest.fn(),
focus: jest.fn(),
diff --git a/packages/editable-html-tip-tap/src/extensions/image-component.jsx b/packages/editable-html-tip-tap/src/extensions/image-component.jsx
index 4e5db4e61..7c9d31c21 100644
--- a/packages/editable-html-tip-tap/src/extensions/image-component.jsx
+++ b/packages/editable-html-tip-tap/src/extensions/image-component.jsx
@@ -125,7 +125,7 @@ function ImageComponent(props) {
);
const applySizeData = useCallback(() => {
- if (!node.attrs.width || !imgRef.current) return;
+ if (!node.attrs.width || !imgRef.current || !imgRef.current.naturalWidth) return;
const resizePercent = getPercentFromWidth(node.attrs.width);
if (node.attrs.resizePercent === resizePercent) return;
updateThisNode({ resizePercent });
@@ -138,7 +138,7 @@ function ImageComponent(props) {
useEffect(() => {
if (selected) {
- if (onlyThisNodeSelected) {
+ if (onlyThisNodeSelected && editor.isEditable) {
// Only open the upload UI for a fresh placeholder. Remounting after tab switch
// would otherwise call insertImageRequested again and reopen the file modal.
const hasImageSrc = String(node.attrs?.src ?? '').trim();
@@ -156,15 +156,17 @@ function ImageComponent(props) {
} else {
setShowToolbar(selected);
}
- }, [onlyThisNodeSelected, selected]);
+ }, [onlyThisNodeSelected, selected, editor.isEditable]);
useEffect(() => {
applySizeData();
- const resizeHandle = resizeRef.current;
- if (resizeHandle) {
+ const resizeHandle = resizeRef?.current;
+
+ if (resizeHandle && editor.isEditable) {
resizeHandle.addEventListener('mousedown', initResize, false);
}
+
return () => {
if (resizeHandle) {
resizeHandle.removeEventListener('mousedown', initResize, false);
@@ -272,7 +274,7 @@ function ImageComponent(props) {
onLoad={loadImage}
alt={node.attrs.alt}
/>
-
+