feat(editor): add placeholder and JSON support to summary editor

- Add @tiptap/extension-placeholder dependency
- Implement placeholder text in summary editor
- Add JSON persistence for summary content
- Enhance HTML sanitization to support safe links
This commit is contained in:
geulah 2025-10-05 23:56:09 +01:00
parent 48b48f8165
commit 3468cf7a43
5 changed files with 60 additions and 4 deletions

View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.90.2",
"@tiptap/extension-placeholder": "^3.6.5",
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"autoprefixer": "^10.4.21",
@ -2022,6 +2023,19 @@
"@tiptap/core": "^3.6.5"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.6.5.tgz",
"integrity": "sha512-9CLixogEb/4UkEyuDr4JdOlLvphcOVfZMdNMKmUVQdqo4MuZCdTDyK5ypfTPQJl8aUo0oCiEhqE0bQerYlueJQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "^3.6.5"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.6.5.tgz",

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.90.2",
"@tiptap/extension-placeholder": "^3.6.5",
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"autoprefixer": "^10.4.21",

View File

@ -2,15 +2,22 @@ import React, { useEffect, useMemo } from 'react';
import { useSummaryData, useCvStore } from '../store/cvStore';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
interface SummaryEditorProps {}
const SummaryEditor: React.FC<SummaryEditorProps> = () => {
const summary = useSummaryData();
const { updateSummary } = useCvStore();
const { updateSummary, updateSummaryJson } = useCvStore();
const editor = useEditor({
extensions: [StarterKit],
extensions: [
StarterKit,
Placeholder.configure({
placeholder: 'Briefly summarize your experience…',
includeChildren: true,
}),
],
content: summary || '',
editorProps: {
attributes: {
@ -20,6 +27,12 @@ const SummaryEditor: React.FC<SummaryEditorProps> = () => {
onUpdate: ({ editor }) => {
const html = editor.getHTML();
updateSummary(html);
try {
const json = editor.getJSON();
updateSummaryJson(json);
} catch {
// ignore JSON persistence errors silently
}
},
});

View File

@ -1,6 +1,21 @@
import { z } from 'zod';
import DOMPurify from 'dompurify';
// Initialize link-safety hook: force rel/target and restrict href schemes
// Note: Hooks may be registered multiple times during HMR; this hook is idempotent.
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node && node.nodeName && node.nodeName.toLowerCase() === 'a') {
const href = node.getAttribute('href') || '';
const isSafe = /^(https?:\/\/|mailto:)/i.test(href);
if (!isSafe) {
node.removeAttribute('href');
}
// Enforce safe link attributes
node.setAttribute('rel', 'noopener noreferrer');
node.setAttribute('target', '_blank');
}
});
// Helper function to generate unique IDs
export const generateId = () => Math.random().toString(36).substring(2, 9);
@ -69,6 +84,8 @@ export const CvSchema = z.object({
date: z.string().optional()
})
).optional().default([]),
// Optional ProseMirror JSON for summary rich content persistence
summaryJson: z.any().optional(),
templateId: z.string().default('ats'),
});
@ -95,6 +112,7 @@ export const createEmptyCV = (): CV => ({
skills: [],
languages: [],
certifications: [],
summaryJson: undefined,
templateId: "ats",
});
@ -112,8 +130,9 @@ export const validateSection = <K extends keyof CV>(section: K, data: CV[K]) =>
export const sanitizeHtml = (html: string): string => {
// Sanitize summary while preserving basic formatting elements
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br'],
ALLOWED_ATTR: []
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
ALLOWED_ATTR: ['href', 'rel', 'target'],
ALLOW_UNKNOWN_PROTOCOLS: false
});
};

View File

@ -22,6 +22,7 @@ interface CvState {
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
updateSkills: (skills: z.infer<typeof CvSchema>['skills']) => void;
updateSummary: (summary: string) => void;
updateSummaryJson: (json: unknown) => void;
updateTemplateId: (templateId: string) => void;
// Work item operations
@ -111,6 +112,13 @@ export const useCvStore = create<CvState>((set, get) => ({
}));
},
updateSummaryJson: (json) => {
set((state) => ({
cv: { ...state.cv, summaryJson: json },
isDirty: true
}));
},
updateTemplateId: (templateId) => {
set((state) => ({
cv: { ...state.cv, templateId },
@ -354,6 +362,7 @@ export const useWorkData = () => useCvStore(state => state.cv.work);
export const useEducationData = () => useCvStore(state => state.cv.education);
export const useSkillsData = () => useCvStore(state => state.cv.skills);
export const useSummaryData = () => useCvStore(state => state.cv.summary);
export const useSummaryJson = () => useCvStore(state => state.cv.summaryJson);
export const useTemplateId = () => useCvStore(state => state.cv.templateId);
export const useActiveStep = () => useCvStore(state => state.activeStep);
export const useIsDirty = () => useCvStore(state => state.isDirty);