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
JavaScript
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;