diff --git a/apps/penxle.com/package.json b/apps/penxle.com/package.json index 0503b07210..937a360fcc 100644 --- a/apps/penxle.com/package.json +++ b/apps/penxle.com/package.json @@ -58,6 +58,7 @@ "argon2": "^0.31.2", "body-scroll-lock": "4.0.0-beta.0", "clsx": "^2.1.0", + "color": "^4.2.3", "dataloader": "^2.2.2", "dayjs": "^1.11.10", "emoji-mart": "^5.5.2", @@ -102,6 +103,7 @@ "@pulumi/pulumi": "^3.103.1", "@sveltejs/vite-plugin-svelte": "^3.0.2", "@types/body-scroll-lock": "^3.1.2", + "@types/color": "^3.0.6", "@types/mixpanel-browser": "^2.48.1", "@types/node": "^20.11.9", "@types/numeral": "^2.0.5", diff --git a/apps/penxle.com/src/lib/server/utils/tiptap.ts b/apps/penxle.com/src/lib/server/utils/tiptap.ts index 2076a0d5dd..3dd91ab619 100644 --- a/apps/penxle.com/src/lib/server/utils/tiptap.ts +++ b/apps/penxle.com/src/lib/server/utils/tiptap.ts @@ -24,7 +24,7 @@ export const revisionContentToText = async (revisionContent: RevisionContent): P }; export const sanitizeContent = async (content: JSONContent[]): Promise => { - traverse(content, async ({ key, value, parent }) => { + traverse(content, ({ key, value, parent }) => { if (parent && key === 'attrs' && typeof value === 'object') { parent.attrs = Object.fromEntries(Object.entries(value).filter(([key]) => !key.startsWith('__'))); } diff --git a/apps/penxle.com/src/lib/tiptap/extensions/font-family.ts b/apps/penxle.com/src/lib/tiptap/extensions/font-family.ts deleted file mode 100644 index b7c9e0cdd3..0000000000 --- a/apps/penxle.com/src/lib/tiptap/extensions/font-family.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Extension } from '@tiptap/core'; -import { Heading, Paragraph } from '$lib/tiptap/nodes'; -import type { FontFamily as TFontFamily } from '@penxle/lib/unocss'; - -declare module '@tiptap/core' { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Commands { - fontFamily: { - setFontFamily: (fontFamily: TFontFamily) => ReturnType; - }; - } -} - -const types = [Heading.name, Paragraph.name]; - -export const FontFamily = Extension.create({ - name: 'font_family', - - addGlobalAttributes() { - return [ - { - types, - attributes: { - 'font-family': { - default: 'sans', - parseHTML: (element) => element.dataset.fontFamily, - renderHTML: (attributes) => ({ - 'data-font-family': attributes['font-family'] as string, - }), - }, - }, - }, - ]; - }, - - addCommands() { - return { - setFontFamily: - (fontFamily) => - ({ commands }) => { - return types.every((type) => commands.updateAttributes(type, { 'font-family': fontFamily })); - }, - }; - }, -}); diff --git a/apps/penxle.com/src/lib/tiptap/extensions/index.ts b/apps/penxle.com/src/lib/tiptap/extensions/index.ts index 47fd8dbf48..b07ea8d4a8 100644 --- a/apps/penxle.com/src/lib/tiptap/extensions/index.ts +++ b/apps/penxle.com/src/lib/tiptap/extensions/index.ts @@ -1,9 +1,4 @@ export * from './drop-cursor'; -export * from './font-family'; export * from './gap-cursor'; export * from './history'; -export * from './letter-spacing'; -export * from './line-height'; -export * from './node-id'; export * from './placeholder'; -export * from './text-align'; diff --git a/apps/penxle.com/src/lib/tiptap/extensions/letter-spacing.ts b/apps/penxle.com/src/lib/tiptap/extensions/letter-spacing.ts deleted file mode 100644 index 71828b6aea..0000000000 --- a/apps/penxle.com/src/lib/tiptap/extensions/letter-spacing.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Extension } from '@tiptap/core'; -import { Heading, Paragraph } from '$lib/tiptap/nodes'; - -// value reference from classname: https://tailwindcss.com/docs/letter-spacing -export type Spacing = 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest'; - -declare module '@tiptap/core' { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Commands { - letterSpacing: { - setLetterSpacing: (spacing: Spacing) => ReturnType; - }; - } -} - -const types = [Heading.name, Paragraph.name]; - -export const LetterSpacing = Extension.create({ - name: 'letter_spacing', - - addGlobalAttributes() { - return [ - { - types, - attributes: { - 'letter-spacing': { - default: 'normal', - parseHTML: (element) => element.dataset.letterSpacing, - renderHTML: (attributes) => ({ - 'data-letter-spacing': attributes['letter-spacing'] as string, - }), - }, - }, - }, - ]; - }, - - addCommands() { - return { - setLetterSpacing: - (height) => - ({ commands }) => { - return types.every((type) => commands.updateAttributes(type, { 'letter-spacing': height })); - }, - }; - }, -}); diff --git a/apps/penxle.com/src/lib/tiptap/extensions/line-height.ts b/apps/penxle.com/src/lib/tiptap/extensions/line-height.ts deleted file mode 100644 index 7ee52e2d89..0000000000 --- a/apps/penxle.com/src/lib/tiptap/extensions/line-height.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Extension } from '@tiptap/core'; -import { Heading, Paragraph } from '$lib/tiptap/nodes'; - -// value reference from tailwind classnames: https://tailwindcss.com/docs/line-height -export type Height = 'none' | 'tight' | 'snug' | 'normal' | 'relaxed' | 'loose'; - -declare module '@tiptap/core' { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Commands { - lineHeight: { - setLineHeight: (height: Height) => ReturnType; - }; - } -} - -const types = [Heading.name, Paragraph.name]; - -export const LineHeight = Extension.create({ - name: 'line_height', - - addGlobalAttributes() { - return [ - { - types, - attributes: { - 'line-height': { - default: 'normal', - parseHTML: (element) => element.dataset.lineHeight, - renderHTML: (attributes) => ({ - 'data-line-height': attributes['line-height'] as string, - }), - }, - }, - }, - ]; - }, - - addCommands() { - return { - setLineHeight: - (height) => - ({ commands }) => { - return types.every((type) => commands.updateAttributes(type, { 'line-height': height })); - }, - }; - }, -}); diff --git a/apps/penxle.com/src/lib/tiptap/extensions/node-id.ts b/apps/penxle.com/src/lib/tiptap/extensions/node-id.ts deleted file mode 100644 index 8bbbc9734b..0000000000 --- a/apps/penxle.com/src/lib/tiptap/extensions/node-id.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Extension } from '@tiptap/core'; -import { Plugin } from '@tiptap/pm/state'; -import { nanoid } from 'nanoid'; - -const types = new Set(['paragraph']); - -export const NodeId = Extension.create({ - name: 'node_id', - - addGlobalAttributes() { - return [ - { - types, - attributes: { - 'node-id': { - rendered: false, - keepOnSplit: false, - }, - }, - }, - ]; - }, - - addProseMirrorPlugins() { - return [ - new Plugin({ - appendTransaction: (_, __, newState) => { - const { tr, doc } = newState; - - doc.descendants((node, pos) => { - if (types.has(node.type.name) && !node.attrs['node-id']) { - tr.setNodeAttribute(pos, 'node-id', nanoid()); - } - - return true; - }); - - return tr; - }, - }), - ]; - }, -}); diff --git a/apps/penxle.com/src/lib/tiptap/extensions/text-align.ts b/apps/penxle.com/src/lib/tiptap/extensions/text-align.ts deleted file mode 100644 index 19d6319e49..0000000000 --- a/apps/penxle.com/src/lib/tiptap/extensions/text-align.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Extension } from '@tiptap/core'; - -export type Alignment = 'left' | 'center' | 'right' | 'justify'; - -declare module '@tiptap/core' { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Commands { - textAlign: { - setTextAlign: (alignment: Alignment) => ReturnType; - }; - } -} - -export const TextAlign = Extension.create({ - name: 'text_align', - - addGlobalAttributes() { - return [ - { - types: ['heading', 'paragraph'], - attributes: { - 'text-align': { - parseHTML: (element) => element.dataset.textAlign, - renderHTML: (attributes) => ({ - 'data-text-align': attributes['text-align'] as string, - }), - }, - }, - }, - ]; - }, - - addCommands() { - return { - setTextAlign: - (alignment) => - ({ commands }) => { - return ['heading', 'paragraph'].every((type) => commands.updateAttributes(type, { 'text-align': alignment })); - }, - }; - }, -}); diff --git a/apps/penxle.com/src/lib/tiptap/legacies/font-family.ts b/apps/penxle.com/src/lib/tiptap/legacies/font-family.ts new file mode 100644 index 0000000000..9ce7a4fa09 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/legacies/font-family.ts @@ -0,0 +1,32 @@ +import { Extension } from '@tiptap/core'; +import clsx from 'clsx'; +import { Paragraph } from '$lib/tiptap/nodes'; +import { LegacyHeading } from './heading'; + +const types = [LegacyHeading.name, Paragraph.name]; + +export const LegacyFontFamily = Extension.create({ + name: 'legacy_font_family', + + addGlobalAttributes() { + return [ + { + types, + attributes: { + 'font-family': { + default: undefined, + renderHTML: ({ 'font-family': fontFamily }) => ({ + class: clsx( + fontFamily === 'sans' && 'font-sans', + fontFamily === 'serif' && 'font-serif', + fontFamily === 'serif2' && 'font-serif2', + fontFamily === 'serif3' && 'font-serif3', + fontFamily === 'mono' && 'font-mono', + ), + }), + }, + }, + }, + ]; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/legacies/heading.ts b/apps/penxle.com/src/lib/tiptap/legacies/heading.ts new file mode 100644 index 0000000000..47cfcb9d2c --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/legacies/heading.ts @@ -0,0 +1,23 @@ +import { mergeAttributes, Node } from '@tiptap/core'; +import clsx from 'clsx'; + +export const LegacyHeading = Node.create({ + name: 'heading', + group: 'block', + content: 'text*', + defining: true, + + addAttributes() { + return { + level: { + renderHTML: ({ level }) => ({ + class: clsx(level === 1 && 'title-24-b', level === 2 && 'title-20-b', level === 3 && 'subtitle-18-b'), + }), + }, + }; + }, + + renderHTML({ node, HTMLAttributes }) { + return [`h${node.attrs.level}`, mergeAttributes(HTMLAttributes, { class: 'my-4' }), 0]; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/legacies/horizontal-rule.ts b/apps/penxle.com/src/lib/tiptap/legacies/horizontal-rule.ts new file mode 100644 index 0000000000..941b3ac38f --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/legacies/horizontal-rule.ts @@ -0,0 +1,35 @@ +import { mergeAttributes, Node } from '@tiptap/core'; +import clsx from 'clsx'; + +export const LegacyHorizontalRule = Node.create({ + name: 'horizontalRule', + group: 'block', + + addAttributes() { + return { + kind: { + default: 1, + renderHTML: ({ kind }) => ({ + class: clsx( + 'bg-no-repeat border-none bg-center m-y-xs m-x-auto', + kind === 1 && 'h-0.0625rem bg-repeat!', + kind === 2 && 'border-1 border-solid border-current', + kind === 3 && 'border-1 border-solid border-current w-7.5rem', + kind === 4 && 'h-1.8rem bg-[url(/horizontal-rules/4.svg)]', + kind === 5 && 'h-0.875rem bg-[url(/horizontal-rules/5.svg)]', + kind === 6 && 'h-0.91027rem bg-[url(/horizontal-rules/6.svg)]', + kind === 7 && 'h-1.25rem bg-[url(/horizontal-rules/7.svg)]', + ), + style: + kind === 1 + ? 'background-size: 16px 1px; background-image: linear-gradient(to right, currentColor 50%, rgb(255 255 255 / 0) 50%);' + : undefined, + }), + }, + }; + }, + + renderHTML({ HTMLAttributes }) { + return ['hr', mergeAttributes(HTMLAttributes, { class: 'bg-no-repeat border-none bg-center m-y-xs m-x-auto' })]; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/legacies/index.ts b/apps/penxle.com/src/lib/tiptap/legacies/index.ts new file mode 100644 index 0000000000..39f2272f2c --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/legacies/index.ts @@ -0,0 +1,7 @@ +export * from './font-family'; +export * from './heading'; +export * from './horizontal-rule'; +export * from './letter-spacing'; +export * from './line-height'; +export * from './text-align'; +export * from './text-color'; diff --git a/apps/penxle.com/src/lib/tiptap/legacies/letter-spacing.ts b/apps/penxle.com/src/lib/tiptap/legacies/letter-spacing.ts new file mode 100644 index 0000000000..87495e7c98 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/legacies/letter-spacing.ts @@ -0,0 +1,34 @@ +import { Extension } from '@tiptap/core'; +import clsx from 'clsx'; +import { Paragraph } from '$lib/tiptap/nodes'; +import { LegacyHeading } from './heading'; + +// value reference from classname: https://tailwindcss.com/docs/letter-spacing +const types = [LegacyHeading.name, Paragraph.name]; + +export const LegacyLetterSpacing = Extension.create({ + name: 'legacy_letter_spacing', + + addGlobalAttributes() { + return [ + { + types, + attributes: { + 'letter-spacing': { + default: undefined, + renderHTML: ({ 'letter-spacing': letterSpacing }) => ({ + class: clsx( + letterSpacing === 'tighter' && 'tracking-tighter', + letterSpacing === 'tight' && 'tracking-tight', + letterSpacing === 'normal' && 'tracking-normal', + letterSpacing === 'wide' && 'tracking-wide', + letterSpacing === 'wider' && 'tracking-wider', + letterSpacing === 'widest' && 'tracking-widest', + ), + }), + }, + }, + }, + ]; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/legacies/line-height.ts b/apps/penxle.com/src/lib/tiptap/legacies/line-height.ts new file mode 100644 index 0000000000..8a0ed9e8f3 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/legacies/line-height.ts @@ -0,0 +1,33 @@ +import { Extension } from '@tiptap/core'; +import clsx from 'clsx'; +import { Paragraph } from '$lib/tiptap/nodes'; +import { LegacyHeading } from './heading'; + +const types = [LegacyHeading.name, Paragraph.name]; + +export const LegacyLineHeight = Extension.create({ + name: 'legacy_line_height', + + addGlobalAttributes() { + return [ + { + types, + attributes: { + 'line-height': { + default: undefined, + renderHTML: ({ 'line-height': lineHeight }) => ({ + class: clsx( + lineHeight === 'none' && 'leading-4', + lineHeight === 'tight' && 'leading-5', + lineHeight === 'snug' && 'leading-6', + lineHeight === 'normal' && 'leading-7', + lineHeight === 'relaxed' && 'leading-8', + lineHeight === 'loose' && 'leading-9', + ), + }), + }, + }, + }, + ]; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/legacies/text-align.ts b/apps/penxle.com/src/lib/tiptap/legacies/text-align.ts new file mode 100644 index 0000000000..4f53bba2b2 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/legacies/text-align.ts @@ -0,0 +1,29 @@ +import { Extension } from '@tiptap/core'; +import clsx from 'clsx'; +import { Paragraph } from '../nodes'; +import { LegacyHeading } from './heading'; + +export const LegacyTextAlign = Extension.create({ + name: 'legacy_text_align', + + addGlobalAttributes() { + return [ + { + types: [LegacyHeading.name, Paragraph.name], + attributes: { + 'text-align': { + default: undefined, + renderHTML: ({ 'text-align': textAlign }) => ({ + class: clsx( + textAlign === 'left' && 'text-left', + textAlign === 'center' && 'text-center', + textAlign === 'right' && 'text-right', + textAlign === 'justify' && 'text-justify', + ), + }), + }, + }, + }, + ]; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/legacies/text-color.ts b/apps/penxle.com/src/lib/tiptap/legacies/text-color.ts new file mode 100644 index 0000000000..8e719da6a4 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/legacies/text-color.ts @@ -0,0 +1,30 @@ +import { Mark } from '@tiptap/core'; +import clsx from 'clsx'; + +export const LegacyTextColor = Mark.create({ + name: 'text-color', + + addAttributes() { + return { + 'data-text-color': { + renderHTML: ({ 'data-text-color': textColor }) => ({ + class: clsx( + (textColor === 'text-gray-50' || textColor === 'text-post-gray') && 'text-post-gray', + (textColor === 'text-gray-40' || textColor === 'text-post-gray2' || textColor === 'text-post-lightgray') && + 'text-post-lightgray', + (textColor === 'text-red-60' || textColor === 'text-post-red') && 'text-post-red', + (textColor === 'text-blue-60' || textColor === 'text-post-blue') && 'text-post-blue', + (textColor === 'text-orange-70' || textColor === 'text-post-brown') && 'text-post-brown', + (textColor === 'text-green-60' || textColor === 'text-post-green') && 'text-post-green', + (textColor === 'text-purple-60' || textColor === 'text-post-purple') && 'text-post-purple', + (textColor === 'text-white' || textColor === 'text-post-white') && 'text-post-white', + ), + }), + }, + }; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0]; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/marks/bold.ts b/apps/penxle.com/src/lib/tiptap/marks/bold.ts index 2042ff7f4c..007db4f9a9 100644 --- a/apps/penxle.com/src/lib/tiptap/marks/bold.ts +++ b/apps/penxle.com/src/lib/tiptap/marks/bold.ts @@ -1,4 +1,4 @@ -import { Mark } from '@tiptap/core'; +import { Mark, mergeAttributes } from '@tiptap/core'; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -16,8 +16,8 @@ export const Bold = Mark.create({ return [{ tag: 'b' }]; }, - renderHTML() { - return ['b', 0]; + renderHTML({ HTMLAttributes }) { + return ['b', mergeAttributes(HTMLAttributes, { class: 'font-bold' }), 0]; }, addCommands() { diff --git a/apps/penxle.com/src/lib/tiptap/marks/font-color.ts b/apps/penxle.com/src/lib/tiptap/marks/font-color.ts new file mode 100644 index 0000000000..d502025fc3 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/marks/font-color.ts @@ -0,0 +1,56 @@ +import { Mark } from '@tiptap/core'; +import Color from 'color'; + +declare module '@tiptap/core' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Commands { + fontColor: { + setFontColor: (fontColor: string) => ReturnType; + unsetFontColor: () => ReturnType; + }; + } +} + +export const FontColor = Mark.create({ + name: 'font_color', + priority: 120, + + addAttributes() { + return { + fontColor: { + parseHTML: (element) => Color(element.style.color).hex().toLowerCase(), + renderHTML: ({ fontColor }) => ({ + style: `color: ${fontColor}`, + }), + }, + }; + }, + + parseHTML() { + return [{ tag: 'span', getAttrs: (node) => !!(node as HTMLElement).style.color && null }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0]; + }, + + addCommands() { + return { + setFontColor: + (fontColor) => + ({ commands }) => { + if (!fontColor.startsWith('#')) { + return false; + } + + return commands.setMark(this.name, { fontColor: fontColor.toLowerCase() }); + }, + + unsetFontColor: + () => + ({ commands }) => { + return commands.unsetMark(this.name); + }, + }; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/marks/font-family.ts b/apps/penxle.com/src/lib/tiptap/marks/font-family.ts new file mode 100644 index 0000000000..d289222de9 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/marks/font-family.ts @@ -0,0 +1,72 @@ +import { Mark } from '@tiptap/core'; +import { values } from '$lib/tiptap/values'; + +const fontFamilies = values.fontFamily.map(({ value }) => value); +type FontFamily = (typeof fontFamilies)[number]; + +declare module '@tiptap/core' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Commands { + fontFamily: { + setFontFamily: (fontFamily: FontFamily) => ReturnType; + unsetFontFamily: () => ReturnType; + }; + } +} + +export const FontFamily = Mark.create({ + name: 'font_family', + priority: 120, + + addAttributes() { + return { + fontFamily: { + parseHTML: (element) => element.style.fontFamily.replace(/^PNXL_/, ''), + renderHTML: ({ fontFamily }) => ({ + style: `font-family: PNXL_${fontFamily}`, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span', + getAttrs: (node) => { + const fontFamily = (node as HTMLElement).style.fontFamily.replace(/^PNXL_/, ''); + + if ((fontFamilies as string[]).includes(fontFamily)) { + return null; + } + + return false; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0]; + }, + + addCommands() { + return { + setFontFamily: + (fontFamily) => + ({ commands }) => { + if (!fontFamilies.includes(fontFamily)) { + return false; + } + + return commands.setMark(this.name, { fontFamily }); + }, + + unsetFontFamily: + () => + ({ commands }) => { + return commands.unsetMark(this.name); + }, + }; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/marks/font-size.ts b/apps/penxle.com/src/lib/tiptap/marks/font-size.ts new file mode 100644 index 0000000000..c4ada69ef5 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/marks/font-size.ts @@ -0,0 +1,63 @@ +import { Mark } from '@tiptap/core'; +import { values } from '$lib/tiptap/values'; +import { closest } from '$lib/utils'; + +const fontSizes = values.fontSize.map(({ value }) => value); + +declare module '@tiptap/core' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Commands { + fontSize: { + setFontSize: (fontSize: number) => ReturnType; + unsetFontSize: () => ReturnType; + }; + } +} + +export const FontSize = Mark.create({ + name: 'font_size', + priority: 120, + + addAttributes() { + return { + fontSize: { + parseHTML: (element) => { + const fontSize = Number.parseFloat(element.style.fontSize.replace(/rem$/, '')) * 16; + return closest(fontSize, fontSizes); + }, + renderHTML: ({ fontSize }) => ({ + style: `font-size: ${fontSize / 16}rem`, + }), + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span', + getAttrs: (element) => (element as HTMLElement).style.fontSize.endsWith('rem') && null, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', HTMLAttributes, 0]; + }, + + addCommands() { + return { + setFontSize: + (fontSize) => + ({ commands }) => { + return commands.setMark(this.name, { fontSize: closest(fontSize, fontSizes) }); + }, + + unsetFontSize: + () => + ({ commands }) => { + return commands.unsetMark(this.name); + }, + }; + }, +}); diff --git a/apps/penxle.com/src/lib/tiptap/marks/index.ts b/apps/penxle.com/src/lib/tiptap/marks/index.ts index 2d25c2bf3a..11b22a4ee2 100644 --- a/apps/penxle.com/src/lib/tiptap/marks/index.ts +++ b/apps/penxle.com/src/lib/tiptap/marks/index.ts @@ -1,6 +1,8 @@ export * from './bold'; +export * from './font-color'; +export * from './font-family'; +export * from './font-size'; export * from './italic'; export * from './link'; export * from './strike'; -export * from './text-color'; export * from './underline'; diff --git a/apps/penxle.com/src/lib/tiptap/marks/italic.ts b/apps/penxle.com/src/lib/tiptap/marks/italic.ts index 99a9947794..57301b824e 100644 --- a/apps/penxle.com/src/lib/tiptap/marks/italic.ts +++ b/apps/penxle.com/src/lib/tiptap/marks/italic.ts @@ -1,4 +1,4 @@ -import { Mark } from '@tiptap/core'; +import { Mark, mergeAttributes } from '@tiptap/core'; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -16,8 +16,8 @@ export const Italic = Mark.create({ return [{ tag: 'i' }]; }, - renderHTML() { - return ['i', 0]; + renderHTML({ HTMLAttributes }) { + return ['i', mergeAttributes(HTMLAttributes, { class: 'font-italic' }), 0]; }, addCommands() { diff --git a/apps/penxle.com/src/lib/tiptap/marks/link.ts b/apps/penxle.com/src/lib/tiptap/marks/link.ts index b8af14284b..ea5a2cd603 100644 --- a/apps/penxle.com/src/lib/tiptap/marks/link.ts +++ b/apps/penxle.com/src/lib/tiptap/marks/link.ts @@ -1,4 +1,11 @@ -import { combineTransactionSteps, findChildrenInRange, getChangedRanges, getMarksBetween, Mark } from '@tiptap/core'; +import { + combineTransactionSteps, + findChildrenInRange, + getChangedRanges, + getMarksBetween, + Mark, + mergeAttributes, +} from '@tiptap/core'; import { Plugin } from '@tiptap/pm/state'; import { find } from 'linkifyjs'; @@ -30,7 +37,15 @@ export const Link = Mark.create({ }, renderHTML({ HTMLAttributes }) { - return ['a', { target: '_blank', rel: 'noreferrer nofollow', ...HTMLAttributes }, 0]; + return [ + 'a', + mergeAttributes(HTMLAttributes, { + class: 'text-disabled underline', + target: '_blank', + rel: 'noreferrer nofollow', + }), + 0, + ]; }, addCommands() { diff --git a/apps/penxle.com/src/lib/tiptap/marks/strike.ts b/apps/penxle.com/src/lib/tiptap/marks/strike.ts index ce2354a91b..2cd5f67d5b 100644 --- a/apps/penxle.com/src/lib/tiptap/marks/strike.ts +++ b/apps/penxle.com/src/lib/tiptap/marks/strike.ts @@ -1,4 +1,4 @@ -import { Mark } from '@tiptap/core'; +import { Mark, mergeAttributes } from '@tiptap/core'; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -16,8 +16,8 @@ export const Strike = Mark.create({ return [{ tag: 's' }]; }, - renderHTML() { - return ['s', 0]; + renderHTML({ HTMLAttributes }) { + return ['s', mergeAttributes(HTMLAttributes, { class: 'line-through' }), 0]; }, addCommands() { diff --git a/apps/penxle.com/src/lib/tiptap/marks/text-color.ts b/apps/penxle.com/src/lib/tiptap/marks/text-color.ts deleted file mode 100644 index 75cf829b34..0000000000 --- a/apps/penxle.com/src/lib/tiptap/marks/text-color.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Mark } from '@tiptap/core'; - -declare module '@tiptap/core' { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Commands { - color: { - setTextColor: (attributes: { 'data-text-color': string }) => ReturnType; - unsetTextColor: () => ReturnType; - }; - } -} - -export const TextColor = Mark.create({ - name: 'text-color', - - addAttributes() { - return { - 'data-text-color': { - rendered: true, - }, - }; - }, - - parseHTML() { - return [{ tag: 'span' }]; - }, - - renderHTML({ HTMLAttributes }) { - return ['span', HTMLAttributes, 0]; - }, - - addCommands() { - return { - setTextColor: - (attributes) => - ({ commands }) => { - return commands.setMark(this.name, attributes); - }, - unsetTextColor: - () => - ({ commands }) => { - return commands.unsetMark(this.name); - }, - }; - }, -}); diff --git a/apps/penxle.com/src/lib/tiptap/marks/underline.ts b/apps/penxle.com/src/lib/tiptap/marks/underline.ts index bd5e938307..572930b089 100644 --- a/apps/penxle.com/src/lib/tiptap/marks/underline.ts +++ b/apps/penxle.com/src/lib/tiptap/marks/underline.ts @@ -1,4 +1,4 @@ -import { Mark } from '@tiptap/core'; +import { Mark, mergeAttributes } from '@tiptap/core'; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -16,8 +16,8 @@ export const Underline = Mark.create({ return [{ tag: 'u' }]; }, - renderHTML() { - return ['u', 0]; + renderHTML({ HTMLAttributes }) { + return ['u', mergeAttributes(HTMLAttributes, { class: 'underline' }), 0]; }, addCommands() { diff --git a/apps/penxle.com/src/lib/tiptap/node-views/embed/Component.svelte b/apps/penxle.com/src/lib/tiptap/node-views/embed/Component.svelte index 7c80cdc89a..becfc17be7 100644 --- a/apps/penxle.com/src/lib/tiptap/node-views/embed/Component.svelte +++ b/apps/penxle.com/src/lib/tiptap/node-views/embed/Component.svelte @@ -60,7 +60,7 @@ - + {#if node.attrs.__data} {#if node.attrs.__data.html}
diff --git a/apps/penxle.com/src/lib/tiptap/nodes/blockquote.ts b/apps/penxle.com/src/lib/tiptap/nodes/blockquote.ts index f05c7e7ef7..2fc7dbf201 100644 --- a/apps/penxle.com/src/lib/tiptap/nodes/blockquote.ts +++ b/apps/penxle.com/src/lib/tiptap/nodes/blockquote.ts @@ -1,13 +1,17 @@ import { Node } from '@tiptap/core'; +import clsx from 'clsx'; +import { closest } from '$lib/utils'; +import { values } from '../values'; -type Kind = 1 | 2 | 3; +const blockquotes = values.blockquote.map(({ value }) => value); +type Blockquote = (typeof blockquotes)[number]; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Commands { - blockQuote: { - setBlockquote: (kind: Kind) => ReturnType; - toggleBlockquote: (kind: Kind) => ReturnType; + blockquote: { + setBlockquote: (kind: Blockquote) => ReturnType; + toggleBlockquote: (kind: Blockquote) => ReturnType; unsetBlockquote: () => ReturnType; }; } @@ -17,20 +21,32 @@ export const inputRegex = /^\s*>\s$/; export const Blockquote = Node.create({ name: 'blockquote', - content: 'prose+', group: 'block', + content: 'paragraph+', defining: true, addAttributes() { return { kind: { - default: 1, - parseHTML: (element) => element.dataset.kind, - renderHTML: (attributes: { kind: Kind }) => { - const align = attributes.kind === 3 ? { 'data-text-align': 'center' } : {}; + isRequired: true, + parseHTML: (element) => { + if (element.dataset.kind === undefined) { + return 1; + } - return { 'data-kind': attributes.kind.toString(), ...align }; + const blockquote = Number.parseInt(element.dataset.kind); + return closest(blockquote, blockquotes); }, + renderHTML: ({ kind }) => ({ + 'class': clsx( + 'border-text-primary pl-0.625rem my-0.34375rem', + kind === 1 && 'border-l-0.1875rem pr-6', + kind === 2 && 'pr-6 before:(block w-2rem content-[url(/blockquotes/carbon.svg)])', + kind === 3 && + 'text-center before:(block w-2rem mx-auto content-[url(/blockquotes/carbon.svg)]) after:(block w-2rem rotate-180 mx-auto content-[url(/blockquotes/carbon.svg)])', + ), + 'data-kind': kind, + }), }, }; }, @@ -62,10 +78,4 @@ export const Blockquote = Node.create({ }, }; }, - - addKeyboardShortcuts() { - return { - 'Mod-Shift-b': () => this.editor.commands.toggleBlockquote(1), - }; - }, }); diff --git a/apps/penxle.com/src/lib/tiptap/nodes/bullet-list.ts b/apps/penxle.com/src/lib/tiptap/nodes/bullet-list.ts index dc001fe107..7f7314b3d9 100644 --- a/apps/penxle.com/src/lib/tiptap/nodes/bullet-list.ts +++ b/apps/penxle.com/src/lib/tiptap/nodes/bullet-list.ts @@ -1,4 +1,4 @@ -import { Node } from '@tiptap/core'; +import { mergeAttributes, Node } from '@tiptap/core'; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -19,7 +19,7 @@ export const BulletList = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ['ul', HTMLAttributes, 0]; + return ['ul', mergeAttributes(HTMLAttributes, { class: 'list-outside list-disc ml-1.25rem' }), 0]; }, addCommands() { diff --git a/apps/penxle.com/src/lib/tiptap/nodes/heading.ts b/apps/penxle.com/src/lib/tiptap/nodes/heading.ts deleted file mode 100644 index cf80237bfe..0000000000 --- a/apps/penxle.com/src/lib/tiptap/nodes/heading.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Node } from '@tiptap/core'; - -type Level = 1 | 2 | 3; - -declare module '@tiptap/core' { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Commands { - heading: { - setHeading: (level: Level) => ReturnType; - }; - } -} - -export const Heading = Node.create({ - name: 'heading', - group: 'block prose', - content: 'text*', - defining: true, - - addAttributes() { - return { - level: { - rendered: false, - }, - }; - }, - - parseHTML() { - return [ - { tag: 'h1', attrs: { level: 1 } }, - { tag: 'h2', attrs: { level: 2 } }, - { tag: 'h3', attrs: { level: 3 } }, - ]; - }, - - renderHTML({ node, HTMLAttributes }) { - return [`h${node.attrs.level}`, HTMLAttributes, 0]; - }, - - addCommands() { - return { - setHeading: - (level) => - ({ commands, state }) => { - const previousAttrs = state.selection.$head.parent.attrs; - return commands.setNode(this.name, { ...previousAttrs, level }); - }, - }; - }, -}); diff --git a/apps/penxle.com/src/lib/tiptap/nodes/horizontal-rule.ts b/apps/penxle.com/src/lib/tiptap/nodes/horizontal-rule.ts index 79e6db9db6..8775078485 100644 --- a/apps/penxle.com/src/lib/tiptap/nodes/horizontal-rule.ts +++ b/apps/penxle.com/src/lib/tiptap/nodes/horizontal-rule.ts @@ -1,92 +1,71 @@ -import { mergeAttributes, Node } from '@tiptap/core'; -import { NodeSelection, TextSelection } from '@tiptap/pm/state'; +import { Node } from '@tiptap/core'; +import clsx from 'clsx'; +import { closest } from '$lib/utils'; +import { values } from '../values'; -export type Kind = 1 | 2 | 3 | 4 | 5 | 6 | 7; -type Attributes = { kind: Kind }; +const horizontalRules = values.horizontalRule.map(({ value }) => value); +type HorizontalRule = (typeof horizontalRules)[number]; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Commands { horizontalRule: { - /** - * Add a horizontal rule - */ - setHorizontalRule: (kind: Kind) => ReturnType; + setHorizontalRule: (kind: HorizontalRule) => ReturnType; }; } } export const HorizontalRule = Node.create({ - name: 'horizontalRule', - - addOptions() { - return { - HTMLAttributes: {}, - }; - }, + name: 'horizontal_rule', + group: 'block', addAttributes() { return { kind: { - default: 1, - parseHTML: (element) => element.dataset.kind && Number.parseInt(element.dataset.kind, 10), - renderHTML: (attributes: Attributes) => ({ 'data-kind': attributes.kind.toString() }), + isRequired: true, + parseHTML: (element) => { + if (element.dataset.kind === undefined) { + return 1; + } + + const horizontalRule = Number.parseInt(element.dataset.kind); + return closest(horizontalRule, horizontalRules); + }, + renderHTML: ({ kind }) => ({ + 'class': clsx( + 'bg-no-repeat border-none bg-center m-y-xs m-x-auto', + kind === 1 && 'h-0.0625rem bg-repeat!', + kind === 2 && 'border-1 border-solid border-current', + kind === 3 && 'border-1 border-solid border-current w-7.5rem', + kind === 4 && 'h-1.8rem bg-[url(/horizontal-rules/4.svg)]', + kind === 5 && 'h-0.875rem bg-[url(/horizontal-rules/5.svg)]', + kind === 6 && 'h-0.91027rem bg-[url(/horizontal-rules/6.svg)]', + kind === 7 && 'h-1.25rem bg-[url(/horizontal-rules/7.svg)]', + ), + 'style': + kind === 1 + ? 'background-size: 16px 1px; background-image: linear-gradient(to right, currentColor 50%, rgb(255 255 255 / 0) 50%);' + : undefined, + 'data-kind': kind, + }), }, }; }, - group: 'block', - parseHTML() { return [{ tag: 'hr' }]; }, renderHTML({ HTMLAttributes }) { - return ['hr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + return ['hr', HTMLAttributes]; }, addCommands() { return { setHorizontalRule: (kind) => - ({ chain }) => { - const currentChain = chain(); - - currentChain.insertContent({ type: this.name, attrs: { kind } }); - - return ( - currentChain - // set cursor after horizontal rule - .command(({ tr, dispatch }) => { - if (dispatch) { - const { $to } = tr.selection; - const posAfter = $to.end(); - - if ($to.nodeAfter) { - if ($to.nodeAfter.isTextblock) { - tr.setSelection(TextSelection.create(tr.doc, $to.pos + 1)); - } else if ($to.nodeAfter.isBlock) { - tr.setSelection(NodeSelection.create(tr.doc, $to.pos)); - } else { - tr.setSelection(TextSelection.create(tr.doc, $to.pos)); - } - } else { - // add node after horizontal rule if it’s the end of the document - const node = $to.parent.type.contentMatch.defaultType?.create(); - - if (node) { - tr.insert(posAfter, node); - tr.setSelection(TextSelection.create(tr.doc, posAfter + 1)); - } - } - - tr.scrollIntoView(); - } - - return true; - }) - .run() - ); + ({ commands }) => { + return commands.insertContent({ type: this.name, attrs: { kind } }); }, }; }, diff --git a/apps/penxle.com/src/lib/tiptap/nodes/index.ts b/apps/penxle.com/src/lib/tiptap/nodes/index.ts index 4280c4cc4e..a44a663f59 100644 --- a/apps/penxle.com/src/lib/tiptap/nodes/index.ts +++ b/apps/penxle.com/src/lib/tiptap/nodes/index.ts @@ -2,7 +2,6 @@ export * from './blockquote'; export * from './bullet-list'; export * from './document'; export * from './hard-break'; -export * from './heading'; export * from './horizontal-rule'; export * from './list-item'; export * from './ordered-list'; diff --git a/apps/penxle.com/src/lib/tiptap/nodes/ordered-list.ts b/apps/penxle.com/src/lib/tiptap/nodes/ordered-list.ts index b627a2f72d..d05ea6a6c2 100644 --- a/apps/penxle.com/src/lib/tiptap/nodes/ordered-list.ts +++ b/apps/penxle.com/src/lib/tiptap/nodes/ordered-list.ts @@ -1,4 +1,4 @@ -import { Node } from '@tiptap/core'; +import { mergeAttributes, Node } from '@tiptap/core'; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -19,7 +19,7 @@ export const OrderedList = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ['ol', HTMLAttributes, 0]; + return ['ol', mergeAttributes(HTMLAttributes, { class: 'list-outside list-decimal ml-1.25rem' }), 0]; }, addCommands() { diff --git a/apps/penxle.com/src/lib/tiptap/nodes/paragraph.ts b/apps/penxle.com/src/lib/tiptap/nodes/paragraph.ts index 1fdb86448d..82c170284f 100644 --- a/apps/penxle.com/src/lib/tiptap/nodes/paragraph.ts +++ b/apps/penxle.com/src/lib/tiptap/nodes/paragraph.ts @@ -1,29 +1,79 @@ -import { Node } from '@tiptap/core'; +import { mergeAttributes, Node } from '@tiptap/core'; +import clsx from 'clsx'; +import { values } from '$lib/tiptap/values'; +import { closest } from '$lib/utils'; -type Level = 1 | 2; -type Attributes = { level: Level }; +const textAligns = values.textAlign.map(({ value }) => value); +type TextAlign = (typeof textAligns)[number]; + +const lineHeights = values.lineHeight.map(({ value }) => value); +type LineHeight = (typeof lineHeights)[number]; + +const letterSpacings = values.letterSpacing.map(({ value }) => value); +type LetterSpacing = (typeof letterSpacings)[number]; declare module '@tiptap/core' { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Commands { paragraph: { - setParagraph: (level: Level) => ReturnType; + setParagraph: () => ReturnType; + setParagraphTextAlign: (textAlign: TextAlign) => ReturnType; + setParagraphLineHeight: (lineHeight: LineHeight) => ReturnType; + setParagraphLetterSpacing: (letterSpacing: LetterSpacing) => ReturnType; }; } } export const Paragraph = Node.create({ name: 'paragraph', - group: 'block prose', + group: 'block', content: 'inline*', priority: 255, addAttributes() { return { level: { - default: 1, - parseHTML: (element) => element.dataset.level && Number.parseInt(element.dataset.level, 10), - renderHTML: (attributes: Attributes) => ({ 'data-level': attributes.level.toString() }), + default: undefined, + renderHTML: ({ level }) => ({ + class: clsx(level === 2 && 'body-13-m'), + }), + }, + + textAlign: { + default: 'left', + parseHTML: (element) => { + const textAlign = element.style.textAlign; + if (!(textAligns as string[]).includes(textAlign)) { + return 'left'; + } + + return textAlign; + }, + renderHTML: ({ textAlign }) => ({ + style: `text-align: ${textAlign}`, + }), + }, + + lineHeight: { + default: 1.6, + parseHTML: (element) => { + const lineHeight = Number.parseFloat(element.style.lineHeight); + return closest(lineHeight, lineHeights) ?? 1.6; + }, + renderHTML: ({ lineHeight }) => ({ + style: `line-height: ${lineHeight}`, + }), + }, + + letterSpacing: { + default: 0, + parseHTML: (element) => { + const letterSpacing = Number.parseFloat(element.style.letterSpacing.replace(/em$/, '')); + return closest(letterSpacing, letterSpacings) ?? 0; + }, + renderHTML: ({ letterSpacing }) => ({ + style: `letter-spacing: ${letterSpacing}em`, + }), }, }; }, @@ -33,16 +83,49 @@ export const Paragraph = Node.create({ }, renderHTML({ node, HTMLAttributes }) { - return !this.editor?.isEditable && node.childCount === 0 ? ['p', HTMLAttributes, ['br']] : ['p', HTMLAttributes, 0]; + return [ + 'p', + mergeAttributes(HTMLAttributes, { class: 'py-0.5' }), + !this.editor?.isEditable && node.childCount === 0 ? ['br'] : 0, + ]; }, addCommands() { return { setParagraph: - (level) => - ({ commands, state }) => { - const previousAttrs = state.selection.$head.parent.attrs; - return commands.setNode(this.name, { ...previousAttrs, level }); + () => + ({ commands }) => { + return commands.setNode(this.name); + }, + + setParagraphTextAlign: + (textAlign) => + ({ commands }) => { + if (!textAligns.includes(textAlign)) { + return false; + } + + return commands.updateAttributes(this.name, { textAlign }); + }, + + setParagraphLineHeight: + (lineHeight) => + ({ commands }) => { + if (!lineHeights.includes(lineHeight)) { + return false; + } + + return commands.updateAttributes(this.name, { lineHeight }); + }, + + setParagraphLetterSpacing: + (letterSpacing) => + ({ commands }) => { + if (!letterSpacings.includes(letterSpacing)) { + return false; + } + + return commands.updateAttributes(this.name, { letterSpacing }); }, }; }, diff --git a/apps/penxle.com/src/lib/tiptap/preset.ts b/apps/penxle.com/src/lib/tiptap/preset.ts index cde0129fff..616d0edeee 100644 --- a/apps/penxle.com/src/lib/tiptap/preset.ts +++ b/apps/penxle.com/src/lib/tiptap/preset.ts @@ -1,21 +1,20 @@ import { production } from '@penxle/lib/environment'; +import { DropCursor, GapCursor, History, Placeholder } from '$lib/tiptap/extensions'; import { - DropCursor, - FontFamily, - GapCursor, - History, - LetterSpacing, - LineHeight, - Placeholder, - TextAlign, -} from '$lib/tiptap/extensions'; -import { Bold, Italic, Link, Strike, TextColor, Underline } from '$lib/tiptap/marks'; + LegacyFontFamily, + LegacyHeading, + LegacyHorizontalRule, + LegacyLetterSpacing, + LegacyLineHeight, + LegacyTextAlign, + LegacyTextColor, +} from '$lib/tiptap/legacies'; +import { Bold, FontColor, FontFamily, FontSize, Italic, Link, Strike, Underline } from '$lib/tiptap/marks'; import { AccessBarrier, Embed, File, Image } from '$lib/tiptap/node-views'; import { BulletList, Document, HardBreak, - Heading, HorizontalRule, ListItem, OrderedList, @@ -31,9 +30,10 @@ export const extensions = [ // nodes HardBreak, - Heading, + LegacyHeading, Paragraph, HorizontalRule, + LegacyHorizontalRule, Blockquote, ListItem, BulletList, @@ -41,9 +41,11 @@ export const extensions = [ // marks Bold, + FontColor, + FontFamily, + FontSize, Italic, Strike, - TextColor, Underline, Link, @@ -52,14 +54,17 @@ export const extensions = [ GapCursor, History, Placeholder, - TextAlign, - FontFamily, - LineHeight, - LetterSpacing, // node views AccessBarrier, ...(production ? [] : [Embed]), File, Image, + + // legacies + LegacyTextColor, + LegacyTextAlign, + LegacyFontFamily, + LegacyLineHeight, + LegacyLetterSpacing, ]; diff --git a/apps/penxle.com/src/lib/tiptap/values.ts b/apps/penxle.com/src/lib/tiptap/values.ts new file mode 100644 index 0000000000..c89ecc1a22 --- /dev/null +++ b/apps/penxle.com/src/lib/tiptap/values.ts @@ -0,0 +1,68 @@ +export const values = { + color: [ + { label: '검정색', value: '#1c1917' }, + { label: '회색', value: '#78716c' }, + { label: '연회색', value: '#a8a29e' }, + { label: '빨간색', value: '#ea4335' }, + { label: '파란색', value: '#4285f4' }, + { label: '갈색', value: '#a96d42' }, + { label: '초록색', value: '#00c75e' }, + { label: '보라색', value: '#9747ff' }, + { label: '흰색', value: '#ffffff' }, + ], + + fontFamily: [ + { label: '프리텐다드', value: 'Pretendard' }, + { label: '리디바탕', value: 'RIDIBatang' }, + { label: 'KoPubWorld 바탕', value: 'KoPubWorldBatang' }, + { label: '나눔명조', value: 'NanumMyeongjo' }, + ], + + fontSize: [ + { label: '8pt', value: 8 }, + { label: '10pt', value: 10 }, + { label: '12pt', value: 12 }, + { label: '14pt', value: 14 }, + { label: '16pt', value: 16 }, + { label: '18pt', value: 18 }, + { label: '20pt', value: 20 }, + { label: '22pt', value: 22 }, + { label: '24pt', value: 24 }, + { label: '36pt', value: 36 }, + { label: '48pt', value: 48 }, + { label: '60pt', value: 60 }, + { label: '72pt', value: 72 }, + ], + + lineHeight: [ + { label: '80%', value: 0.8 }, + { label: '100%', value: 1 }, + { label: '120%', value: 1.2 }, + { label: '140%', value: 1.4 }, + { label: '160%', value: 1.6 }, + { label: '180%', value: 1.8 }, + { label: '200%', value: 2 }, + { label: '220%', value: 2.2 }, + ], + + letterSpacing: [ + { label: '-10%', value: -0.1 }, + { label: '-5%', value: -0.05 }, + { label: '0%', value: 0 }, + { label: '5%', value: 0.05 }, + { label: '10%', value: 0.1 }, + { label: '20%', value: 0.2 }, + { label: '40%', value: 0.4 }, + ], + + textAlign: [ + { label: '왼쪽', value: 'left', icon: 'i-lc-align-left' }, + { label: '중앙', value: 'center', icon: 'i-lc-align-center' }, + { label: '오른쪽', value: 'right', icon: 'i-lc-align-right' }, + { label: '양쪽', value: 'justify', icon: 'i-lc-align-justify' }, + ], + + horizontalRule: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }, { value: 5 }, { value: 6 }, { value: 7 }], + + blockquote: [{ value: 1 }, { value: 2 }, { value: 3 }], +} as const; diff --git a/apps/penxle.com/src/lib/utils/number.ts b/apps/penxle.com/src/lib/utils/number.ts index ab06329743..935c3ea86d 100644 --- a/apps/penxle.com/src/lib/utils/number.ts +++ b/apps/penxle.com/src/lib/utils/number.ts @@ -37,3 +37,19 @@ export const base36To10 = (value: string) => { return result.toString(); }; + +export const closest = (value: number, array: number[]) => { + if (Number.isNaN(value) || array.length === 0) { + return null; + } + + let r = array[0]; + + for (let i = 1; i < array.length; i++) { + if (Math.abs(value - array[i]) < Math.abs(value - r)) { + r = array[i]; + } + } + + return r; +}; diff --git a/apps/penxle.com/src/routes/editor/ArticleBubbleMenu.svelte b/apps/penxle.com/src/routes/editor/ArticleBubbleMenu.svelte index 4d29c7b4a9..d332285ae6 100644 --- a/apps/penxle.com/src/routes/editor/ArticleBubbleMenu.svelte +++ b/apps/penxle.com/src/routes/editor/ArticleBubbleMenu.svelte @@ -5,7 +5,7 @@ import { Tooltip } from '$lib/components'; import { Menu, MenuItem } from '$lib/components/menu'; import { TiptapBubbleMenu } from '$lib/tiptap/components'; - import { alignments, colors, fonts, getToggledFormat, heading, heights, spacing, texts } from './formats.svelte'; + import { values } from '$lib/tiptap/values'; import type { Editor } from '@tiptap/core'; export let editor: Editor; @@ -25,9 +25,6 @@ } const offset = 32; - - $: currentNode = editor.state.selection.$head.parent; - $: toggledFormat = getToggledFormat(currentNode);
- {#each colors as color (color.value)} + {#each values.color as color (color.value)} { - const commands = editor.chain().focus(); - - if (color.value) { - commands.setTextColor({ 'data-text-color': color.value }).run(); + if (color.value === values.color[0].value) { + editor.chain().focus().unsetFontColor().run(); } else { - commands.unsetTextColor().run(); + editor.chain().focus().setFontColor(color.value).run(); } }} > -
+
{color.label}
@@ -70,55 +62,63 @@ - - -
- {toggledFormat.text.label} - -
+ + + - {#each texts as text (`${text.name}-${text.level}}`)} + {#each values.fontSize as fontSize (fontSize.value)} { - const commands = editor.chain().focus(); - if (text.name === heading) { - commands.setHeading(text.level).run(); + if (fontSize.value === 16) { + editor.chain().focus().unsetFontSize().run(); } else { - commands.setParagraph(text.level).run(); + editor.chain().focus().setFontSize(fontSize.value).run(); } }} > -
{text.label}
- {#if editor.isActive(text.name, { level: text.level })} - - {/if} + {fontSize.label} +
{/each}
- +
- {toggledFormat.font.label} + {values.fontFamily.find(({ value }) => editor.getAttributes('font_family').fontFamily === value)?.label ?? + values.fontFamily[0].label}
- {#each fonts as font (font.value)} + {#each values.fontFamily as font (font.value)} { - editor.chain().focus().setFontFamily(font.value).run(); + if (font.value === values.fontFamily[0].value) { + editor.chain().focus().unsetFontFamily().run(); + } else { + editor.chain().focus().setFontFamily(font.value).run(); + } }} > - {font.label} + + {font.label} + @@ -173,16 +173,23 @@ {offset} placement="bottom-start" > - + value === editor.getAttributes('paragraph').textAlign)?.icon ?? + values.textAlign[0].icon, + 'square-1rem', + )} + /> - {#each alignments as alignment (alignment.value)} + {#each values.textAlign as textAlign (textAlign.value)} {/each}
@@ -239,20 +246,20 @@ - {#each heights as height (height.value)} + {#each values.lineHeight as lineHeight (lineHeight.value)} { - editor.chain().focus().setLineHeight(height.value).run(); + editor.chain().focus().setParagraphLineHeight(lineHeight.value).run(); }} > - {height.label} + {lineHeight.label} @@ -264,20 +271,20 @@ - {#each spacing as space (space.value)} + {#each values.letterSpacing as letterSpacing (letterSpacing.value)} { - editor.chain().focus().setLetterSpacing(space.value).run(); + editor.chain().focus().setParagraphLetterSpacing(letterSpacing.value).run(); }} > - {space.label} + {letterSpacing.label} diff --git a/apps/penxle.com/src/routes/editor/ArticleFloatingMenu.svelte b/apps/penxle.com/src/routes/editor/ArticleFloatingMenu.svelte index 0c721b346e..850c2f9a39 100644 --- a/apps/penxle.com/src/routes/editor/ArticleFloatingMenu.svelte +++ b/apps/penxle.com/src/routes/editor/ArticleFloatingMenu.svelte @@ -2,8 +2,8 @@ import { Button } from '$lib/components'; import { Menu, MenuItem } from '$lib/components/menu'; import { TiptapFloatingMenu } from '$lib/tiptap/components'; + import { values } from '$lib/tiptap/values'; import { isValidImageFile, validImageMimes } from '$lib/utils'; - import { blockquotes, hr } from './formats.svelte'; import type { Editor } from '@tiptap/core'; export let editor: Editor; @@ -70,14 +70,14 @@ 구분선 추가
- {#each hr as kind (kind)} + {#each values.horizontalRule as hr (hr.value)} { - editor.chain().focus().setHorizontalRule(kind).run(); + editor.chain().focus().setHorizontalRule(hr.value).run(); }} > -
+
{/each} @@ -99,14 +99,18 @@ 인용구 - {#each blockquotes as kind (kind)} + {#each values.blockquote as blockquote (blockquote.value)} { - editor.chain().focus().setBlockquote(kind).run(); + editor.chain().focus().setBlockquote(blockquote.value).run(); }} > -
+
내용을 입력해주세요
diff --git a/apps/penxle.com/src/routes/editor/formats.svelte b/apps/penxle.com/src/routes/editor/formats.svelte deleted file mode 100644 index 8160238e7f..0000000000 --- a/apps/penxle.com/src/routes/editor/formats.svelte +++ /dev/null @@ -1,117 +0,0 @@ - diff --git a/apps/penxle.com/src/styles/prose.css b/apps/penxle.com/src/styles/prose.css index e19bbe890d..f91042863a 100644 --- a/apps/penxle.com/src/styles/prose.css +++ b/apps/penxle.com/src/styles/prose.css @@ -1,295 +1,15 @@ -.ProseMirror:not(:has(.tiptap-embedded)) { - /* - * nodes - */ +.ProseMirror { --uno: whitespace-pre-wrap break-all; - & p { - --uno: py-0.5; - } - - & p[data-level='2'] { - --uno: body-13-m; - } - - & h1 { - --uno: title-24-b my-4; - } - - & h2 { - --uno: title-20-b my-4; - } - - & h3 { - --uno: subtitle-18-b my-4; - } - - & hr { - --uno: bg-no-repeat border-none bg-center m-y-xs m-x-auto; - } - - & hr[data-kind='1'] { - background-image: linear-gradient(to right, currentColor 50%, rgb(255 255 255 / 0) 50%); - background-repeat: repeat; - background-size: 16px 1px; - height: 0.0625rem; - } - - & hr[data-kind='2'], - & hr[data-kind='3'] { - border: solid 1px currentColor; - } - - & hr[data-kind='3'] { - width: 7.5rem; - } - - & hr[data-kind='4'] { - --uno: h-1.8rem; - background-image: url(https://penxle.com/horizontal-rules/4.svg); - } - - & hr[data-kind='5'] { - --uno: h-0.875rem; - background-image: url(https://penxle.com/horizontal-rules/5.svg); - } - - & hr[data-kind='6'] { - --uno: h-0.91027rem; - background-image: url(https://penxle.com/horizontal-rules/6.svg); - } - - & hr[data-kind='7'] { - --uno: h-1.25rem; - background-image: url(https://penxle.com/horizontal-rules/7.svg); - } - - & blockquote { - --uno: border-l-0.1875rem border-text-primary pl-0.625rem my-0.34375rem p-r-6; - } - - & blockquote[data-kind='2'] { - --uno: border-l-none; - &:before { - --uno: block w-2rem; - content: url(https://penxle.com/blockquotes/carbon.svg); - } - } - - & blockquote[data-kind='3'] { - --uno: border-l-none p-r-none; - &:before { - --uno: block w-2rem m-x-auto; - content: url(https://penxle.com/blockquotes/carbon.svg); - } - &:after { - --uno: block w-2rem rotate-180 m-x-auto; - content: url(https://penxle.com/blockquotes/carbon.svg); - } - } - - & ul, - ol { - list-style-position: outside; - white-space: pre-wrap; - margin-left: 1.25rem; - } - - & ul { - list-style-type: disc; - } - - & ol { - list-style-type: decimal; - } - & li > p:first-child { display: inline; } - /* - * marks - */ - - & a { - --uno: underline text-disabled; - } - - & b { - --uno: font-bold; - } - - & i { - --uno: font-italic; - } - - & s { - --uno: line-through; - } - - & u { - --uno: underline; - } - - & [data-text-color='text-gray-50'], - & [data-text-color='text-post-gray'] { - --uno: text-post-gray; - } - - & [data-text-color='text-gray-40'], - & [data-text-color='text-post-gray2'], - & [data-text-color='text-post-lightgray'] { - --uno: color-post-lightgray; - } - - & [data-text-color='text-red-60'], - & [data-text-color='text-post-red'] { - --uno: text-post-red; - } - - & [data-text-color='text-blue-60'], - & [data-text-color='text-post-blue'] { - --uno: text-post-blue; - } - - & [data-text-color='text-orange-70'], - & [data-text-color='text-post-brown'] { - --uno: text-post-brown; - } - - & [data-text-color='text-green-60'], - & [data-text-color='text-post-green'] { - --uno: text-post-green; - } - - & [data-text-color='text-purple-60'], - & [data-text-color='text-post-purple'] { - --uno: text-post-purple; - } - - & [data-text-color='text-white'], - & [data-text-color='text-post-white'] { - --uno: text-post-white; - } - - /* - * extensions - */ - - & [data-font-family='sans'] { - --uno: font-sans; - } - - & [data-font-family='serif'] { - --uno: font-serif; - } - - & [data-font-family='serif2'] { - --uno: font-serif2; - } - - & [data-font-family='serif3'] { - --uno: font-serif3; - } - - & [data-font-family='mono'] { - --uno: font-mono; - } - - & [data-line-height='none'] { - --uno: leading-4; - } - - & [data-line-height='tight'] { - --uno: leading-5; - } - - & [data-line-height='snug'] { - --uno: leading-6; - } - - & [data-line-height='normal'] { - --uno: leading-7; - } - - & [data-line-height='relaxed'] { - --uno: leading-8; - } - - & [data-line-height='loose'] { - --uno: leading-9; - } - - & [data-font-family^='serif'] { - &[data-line-height='none'] { - --uno: leading-4.5; - } - - &[data-line-height='tight'] { - --uno: leading-5.5; - } - - &[data-line-height='snug'] { - --uno: leading-6.5; - } - - &[data-line-height='normal'] { - --uno: leading-7.5; - } - - &[data-line-height='relaxed'] { - --uno: leading-8.5; - } - - &[data-line-height='loose'] { - --uno: leading-9.5; - } - } - - & [data-letter-spacing='tighter'] { - --uno: tracking-tighter; - } - - & [data-letter-spacing='tight'] { - --uno: tracking-tight; - } - - & [data-letter-spacing='wide'] { - --uno: tracking-wide; - } - - & [data-letter-spacing='wider'] { - --uno: tracking-wider; - } - - & [data-letter-spacing='widest'] { - --uno: tracking-widest; - } - - & [data-text-align='left'] { - --uno: text-left; - } - & [data-text-align='center'] { - --uno: text-center; - } - - & [data-text-align='right'] { - --uno: text-right; - } - - & [data-text-align='justify'] { - --uno: text-justify; - } - & p.is-editor-empty:first-child::before { --uno: text-gray-40 h-0 pointer-events-none float-left; content: attr(data-placeholder); } - /* - * others - */ - & [data-drag-handle] { --uno: cursor-grab; } diff --git a/apps/penxle.com/uno.config.ts b/apps/penxle.com/uno.config.ts index b839a87ab8..f853958874 100644 --- a/apps/penxle.com/uno.config.ts +++ b/apps/penxle.com/uno.config.ts @@ -2,6 +2,7 @@ import { presetPenxle } from '@penxle/lib/unocss'; import { defineConfig, transformerDirectives, transformerVariantGroup } from 'unocss'; export default defineConfig({ + content: { pipeline: { include: ['**/*.{svelte,ts}'] } }, presets: [presetPenxle()], transformers: [transformerDirectives(), transformerVariantGroup()], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d43cb169d8..c1af6c2a44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,6 +352,9 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.0 + color: + specifier: ^4.2.3 + version: 4.2.3 dataloader: specifier: ^2.2.2 version: 2.2.2 @@ -479,6 +482,9 @@ importers: '@types/body-scroll-lock': specifier: ^3.1.2 version: 3.1.2 + '@types/color': + specifier: ^3.0.6 + version: 3.0.6 '@types/mixpanel-browser': specifier: ^2.48.1 version: 2.48.1 @@ -5576,6 +5582,22 @@ packages: '@types/node': 20.11.9 '@types/responselike': 1.0.3 + /@types/color-convert@2.0.3: + resolution: {integrity: sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg==} + dependencies: + '@types/color-name': 1.1.3 + dev: true + + /@types/color-name@1.1.3: + resolution: {integrity: sha512-87W6MJCKZYDhLAx/J1ikW8niMvmGRyY+rpUxWpL1cO7F8Uu5CHuQoFv+R0/L5pgNdW4jTyda42kv60uwVIPjLw==} + dev: true + + /@types/color@3.0.6: + resolution: {integrity: sha512-NMiNcZFRUAiUUCCf7zkAelY8eV3aKqfbzyFQlXpPIEeoNDbsEHGpb854V3gzTsGKYj830I5zPuOwU/TP5/cW6A==} + dependencies: + '@types/color-convert': 2.0.3 + dev: true + /@types/connect@3.4.38: resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} dependencies: