UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

392 lines (391 loc) 15.8 kB
import { Node, mergeAttributes } from '@tiptap/core'; import { PluginKey } from '@tiptap/pm/state'; import { Plugin } from '@tiptap/pm/state'; export const PollExtension = Node.create({ name: 'poll', addOptions() { return { HTMLAttributes: { class: 'poll-block', 'data-type': 'poll', }, onVote: async (pollId, optionId, userId) => { console.log('Vote cast:', { pollId, optionId, userId }); }, onUnvote: async (pollId, optionId, userId) => { console.log('Vote removed:', { pollId, optionId, userId }); }, onPollCreate: async (pollData) => { console.log('Poll created:', pollData); return `poll_${Date.now()}`; }, onPollUpdate: async (pollId, updates) => { console.log('Poll updated:', { pollId, updates }); }, getCurrentUserId: () => 'current_user', canVote: (pollId, userId) => true, canEdit: (pollId, userId) => true, }; }, group: 'block', content: '', atom: true, addAttributes() { return { pollId: { default: null, parseHTML: element => element.getAttribute('data-poll-id'), renderHTML: attributes => { if (!attributes.pollId) { return {}; } return { 'data-poll-id': attributes.pollId }; }, }, question: { default: '', parseHTML: element => element.getAttribute('data-question'), renderHTML: attributes => { if (!attributes.question) { return {}; } return { 'data-question': attributes.question }; }, }, options: { default: [], parseHTML: element => { const optionsData = element.getAttribute('data-options'); return optionsData ? JSON.parse(optionsData) : []; }, renderHTML: attributes => { if (!attributes.options || attributes.options.length === 0) { return {}; } return { 'data-options': JSON.stringify(attributes.options) }; }, }, allowMultiple: { default: false, parseHTML: element => element.getAttribute('data-allow-multiple') === 'true', renderHTML: attributes => { return { 'data-allow-multiple': attributes.allowMultiple.toString() }; }, }, anonymous: { default: false, parseHTML: element => element.getAttribute('data-anonymous') === 'true', renderHTML: attributes => { return { 'data-anonymous': attributes.anonymous.toString() }; }, }, deadline: { default: null, parseHTML: element => element.getAttribute('data-deadline'), renderHTML: attributes => { if (!attributes.deadline) { return {}; } return { 'data-deadline': attributes.deadline }; }, }, createdBy: { default: '', parseHTML: element => element.getAttribute('data-created-by'), renderHTML: attributes => { if (!attributes.createdBy) { return {}; } return { 'data-created-by': attributes.createdBy }; }, }, status: { default: 'draft', parseHTML: element => element.getAttribute('data-status') || 'draft', renderHTML: attributes => { return { 'data-status': attributes.status }; }, }, }; }, parseHTML() { return [ { tag: 'div[data-type="poll"]', }, ]; }, renderHTML({ node, HTMLAttributes }) { const { pollId, question, options = [], allowMultiple, anonymous, deadline, createdBy, status, } = node.attrs; const currentUserId = this.options.getCurrentUserId(); const canVote = this.options.canVote(pollId, currentUserId); const canEdit = this.options.canEdit(pollId, currentUserId); const totalVotes = options.reduce((sum, option) => sum + option.votes, 0); return [ 'div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-poll-id': pollId, 'data-question': question, 'data-options': JSON.stringify(options), 'data-allow-multiple': allowMultiple.toString(), 'data-anonymous': anonymous.toString(), 'data-deadline': deadline, 'data-created-by': createdBy, 'data-status': status, }), [ 'div', { class: 'poll-header' }, [ 'div', { class: 'poll-question' }, question || 'What\'s your question?', ], [ 'div', { class: 'poll-meta' }, [ 'span', { class: 'poll-votes-count' }, `${totalVotes} vote${totalVotes !== 1 ? 's' : ''}`, ], deadline && [ 'span', { class: 'poll-deadline' }, `Ends: ${new Date(deadline).toLocaleDateString()}`, ], status === 'closed' && [ 'span', { class: 'poll-status-closed' }, 'Closed', ], ].filter(Boolean), ], [ 'div', { class: 'poll-options' }, ...options.map((option) => { const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0; const hasVoted = option.voters.includes(currentUserId); return [ 'div', { class: `poll-option ${hasVoted ? 'voted' : ''} ${canVote ? 'clickable' : ''}`, 'data-option-id': option.id, }, [ 'div', { class: 'poll-option-content' }, [ 'div', { class: 'poll-option-text' }, option.text, ], [ 'div', { class: 'poll-option-stats' }, [ 'span', { class: 'poll-option-percentage' }, `${percentage}%`, ], [ 'span', { class: 'poll-option-votes' }, `${option.votes} vote${option.votes !== 1 ? 's' : ''}`, ], ], ], [ 'div', { class: 'poll-option-bar', style: `width: ${percentage}%`, }, ], hasVoted && [ 'div', { class: 'poll-option-voted-indicator' }, '✓', ], ].filter(Boolean); }), ], status === 'draft' && canEdit && [ 'div', { class: 'poll-actions' }, [ 'button', { class: 'poll-action-button poll-add-option', type: 'button', 'data-action': 'add-option', }, '+ Add Option', ], [ 'button', { class: 'poll-action-button poll-publish', type: 'button', 'data-action': 'publish', }, 'Publish Poll', ], ], ].filter(Boolean); }, addCommands() { return { insertPoll: (pollData) => ({ commands }) => { const defaultPoll = { pollId: `poll_${Date.now()}`, question: pollData.question || 'What\'s your question?', options: pollData.options || [ { id: 'opt1', text: 'Option 1', votes: 0, voters: [] }, { id: 'opt2', text: 'Option 2', votes: 0, voters: [] }, ], allowMultiple: pollData.allowMultiple || false, anonymous: pollData.anonymous || false, deadline: pollData.deadline, createdBy: pollData.createdBy || this.options.getCurrentUserId(), status: pollData.status || 'draft', }; return commands.insertContent({ type: this.name, attrs: defaultPoll, }); }, updatePoll: (pollId, updates) => ({ tr, state }) => { const { doc } = state; let updated = false; doc.descendants((node, pos) => { if (node.type === this.type && node.attrs.pollId === pollId) { const newAttrs = { ...node.attrs, ...updates }; tr.setNodeMarkup(pos, undefined, newAttrs); updated = true; return false; } }); return updated; }, }; }, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey('poll'), props: { handleClick: (view, pos, event) => { const target = event.target; const pollBlock = target.closest('[data-type="poll"]'); if (!pollBlock) return false; const pollId = pollBlock.getAttribute('data-poll-id'); const currentUserId = this.options.getCurrentUserId(); // Handle option voting const pollOption = target.closest('.poll-option'); if (pollOption && this.options.canVote(pollId, currentUserId)) { event.preventDefault(); event.stopPropagation(); const optionId = pollOption.getAttribute('data-option-id'); if (optionId) { this.handleVote(pollId, optionId, currentUserId); } return true; } // Handle action buttons const actionButton = target.closest('[data-action]'); if (actionButton) { event.preventDefault(); event.stopPropagation(); const action = actionButton.getAttribute('data-action'); this.handlePollAction(pollId, action, currentUserId); return true; } return false; }, }, }), ]; }, // Helper methods async handleVote(pollId, optionId, userId) { try { // Get current poll data const pollNode = this.findPollNode(pollId); if (!pollNode) return; const { options, allowMultiple } = pollNode.attrs; const option = options.find((opt) => opt.id === optionId); if (!option) return; const hasVoted = option.voters.includes(userId); if (hasVoted) { // Remove vote await this.options.onUnvote(pollId, optionId, userId); option.votes = Math.max(0, option.votes - 1); option.voters = option.voters.filter((id) => id !== userId); } else { // Add vote if (!allowMultiple) { // Remove votes from other options if single choice options.forEach((opt) => { if (opt.id !== optionId && opt.voters.includes(userId)) { opt.votes = Math.max(0, opt.votes - 1); opt.voters = opt.voters.filter((id) => id !== userId); } }); } await this.options.onVote(pollId, optionId, userId); option.votes += 1; option.voters.push(userId); } // Update the poll in the editor this.editor.commands.updatePoll(pollId, { options }); } catch (error) { console.error('Failed to handle vote:', error); } }, async handlePollAction(pollId, action, userId) { try { const pollNode = this.findPollNode(pollId); if (!pollNode) return; switch (action) { case 'add-option': const newOption = { id: `opt_${Date.now()}`, text: `Option ${pollNode.attrs.options.length + 1}`, votes: 0, voters: [], }; const updatedOptions = [...pollNode.attrs.options, newOption]; this.editor.commands.updatePoll(pollId, { options: updatedOptions }); break; case 'publish': await this.options.onPollUpdate(pollId, { status: 'active' }); this.editor.commands.updatePoll(pollId, { status: 'active' }); break; default: console.warn('Unknown poll action:', action); } } catch (error) { console.error('Failed to handle poll action:', error); } }, findPollNode(pollId) { let foundNode = null; this.editor.state.doc.descendants((node) => { if (node.type === this.type && node.attrs.pollId === pollId) { foundNode = node; return false; } }); return foundNode; }, }); export default PollExtension;