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:
parent
48b48f8165
commit
3468cf7a43
14
cv-engine/package-lock.json
generated
14
cv-engine/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
@ -110,6 +111,13 @@ export const useCvStore = create<CvState>((set, get) => ({
|
||||
isDirty: true
|
||||
}));
|
||||
},
|
||||
|
||||
updateSummaryJson: (json) => {
|
||||
set((state) => ({
|
||||
cv: { ...state.cv, summaryJson: json },
|
||||
isDirty: true
|
||||
}));
|
||||
},
|
||||
|
||||
updateTemplateId: (templateId) => {
|
||||
set((state) => ({
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user