tabs to spaces whitespace

This commit is contained in:
Philipp Kühn
2018-11-08 22:03:10 +01:00
parent b8b82220ba
commit f04a6be6c1
114 changed files with 4214 additions and 4214 deletions

View File

@@ -5,20 +5,20 @@ import config from './webpack.config'
const spinner = ora('Building …')
export default new Promise((resolve, reject) => {
spinner.start()
spinner.start()
webpack(config, (error, stats) => {
if (error) {
return reject(error)
}
webpack(config, (error, stats) => {
if (error) {
return reject(error)
}
if (stats.hasErrors()) {
process.stdout.write(stats.toString() + "\n");
return reject(new Error('Build failed with errors.'))
}
if (stats.hasErrors()) {
process.stdout.write(stats.toString() + "\n");
return reject(new Error('Build failed with errors.'))
}
return resolve('Build complete.')
})
return resolve('Build complete.')
})
})
.then(success => spinner.succeed(success))
.catch(error => spinner.fail(error))

View File

@@ -16,11 +16,11 @@ middlewares.push(historyApiFallbackMiddleware())
// add webpack stuff
middlewares.push(webpackDevMiddleware(bundler, {
publicPath: config.output.publicPath,
stats: {
colors: true,
chunks: false,
},
publicPath: config.output.publicPath,
stats: {
colors: true,
chunks: false,
},
}))
// add hot reloading
@@ -30,25 +30,25 @@ middlewares.push(webpackHotMiddleware(bundler))
const url = 'http://localhost'
const bs = browserSync.create()
const server = bs.init({
server: {
baseDir: `${srcPath}/`,
middleware: middlewares,
},
files: [],
logLevel: 'silent',
open: false,
notify: false,
injectChanges: false,
ghostMode: {
clicks: false,
forms: false,
scroll: false,
},
server: {
baseDir: `${srcPath}/`,
middleware: middlewares,
},
files: [],
logLevel: 'silent',
open: false,
notify: false,
injectChanges: false,
ghostMode: {
clicks: false,
forms: false,
scroll: false,
},
})
console.log(`${url}:${server.options.get('port')}`)
// sass import
bs.watch(path.join(sassImportPath, '**/!(index|index_sub).scss'), { ignoreInitial: true }, () => {
sassImport(sassImportPath)
sassImport(sassImportPath)
})

View File

@@ -6,11 +6,11 @@ import minimist from 'minimist'
let argv = minimist(process.argv.slice(2))
export function removeEmpty(array) {
return array.filter(entry => !!entry)
return array.filter(entry => !!entry)
}
export function ifElse(condition) {
return (then, otherwise) => (condition ? then : otherwise)
return (then, otherwise) => (condition ? then : otherwise)
}
export const env = argv.env || 'development'
@@ -23,25 +23,25 @@ export const ifProd = ifElse(isProd)
export const ifTest = ifElse(isTest)
export function sassImport(basePath) {
const indexFileName = 'index.scss'
glob.sync(`${basePath}/**/${indexFileName}`).forEach(sourceFile => {
fs.writeFileSync(sourceFile, '// This is a dynamically generated file \n\n')
glob.sync(`${path.dirname(sourceFile)}/*.scss`).forEach(file => {
if (path.basename(file) !== indexFileName) {
fs.appendFileSync(sourceFile, `@import "${path.basename(file)}";\n`)
}
})
})
const indexFileName = 'index.scss'
glob.sync(`${basePath}/**/${indexFileName}`).forEach(sourceFile => {
fs.writeFileSync(sourceFile, '// This is a dynamically generated file \n\n')
glob.sync(`${path.dirname(sourceFile)}/*.scss`).forEach(file => {
if (path.basename(file) !== indexFileName) {
fs.appendFileSync(sourceFile, `@import "${path.basename(file)}";\n`)
}
})
})
const indexSubFileName = 'index_sub.scss'
glob.sync(`${basePath}/**/${indexSubFileName}`).forEach(sourceFile => {
fs.writeFileSync(sourceFile, '// This is a dynamically generated file \n\n')
glob.sync(`${path.dirname(sourceFile)}/**/*.scss`).forEach(file => {
if (path.basename(file) !== indexSubFileName) {
let importPath = (path.dirname(sourceFile) === path.dirname(file)) ? path.basename(file) : file
importPath = importPath.replace(`${path.dirname(sourceFile)}/`, '')
fs.appendFileSync(sourceFile, `@import "${importPath}";\n`)
}
})
})
const indexSubFileName = 'index_sub.scss'
glob.sync(`${basePath}/**/${indexSubFileName}`).forEach(sourceFile => {
fs.writeFileSync(sourceFile, '// This is a dynamically generated file \n\n')
glob.sync(`${path.dirname(sourceFile)}/**/*.scss`).forEach(file => {
if (path.basename(file) !== indexSubFileName) {
let importPath = (path.dirname(sourceFile) === path.dirname(file)) ? path.basename(file) : file
importPath = importPath.replace(`${path.dirname(sourceFile)}/`, '')
fs.appendFileSync(sourceFile, `@import "${importPath}";\n`)
}
})
})
}

View File

@@ -13,214 +13,214 @@ import { rootPath, srcPath, buildPath } from './paths'
export default {
mode: ifDev('development', 'production'),
mode: ifDev('development', 'production'),
entry: {
app: removeEmpty([
ifDev('webpack-hot-middleware/client?reload=true'),
`${srcPath}/assets/sass/main.scss`,
`${srcPath}/main.js`,
]),
},
entry: {
app: removeEmpty([
ifDev('webpack-hot-middleware/client?reload=true'),
`${srcPath}/assets/sass/main.scss`,
`${srcPath}/main.js`,
]),
},
output: {
path: `${buildPath}/`,
filename: `assets/js/[name]${ifProd('.[hash]', '')}.js`,
chunkFilename: `assets/js/[name]${ifProd('.[chunkhash]', '')}.js`,
publicPath: '/',
},
output: {
path: `${buildPath}/`,
filename: `assets/js/[name]${ifProd('.[hash]', '')}.js`,
chunkFilename: `assets/js/[name]${ifProd('.[chunkhash]', '')}.js`,
publicPath: '/',
},
resolve: {
extensions: ['.js', '.scss', '.vue'],
alias: {
vue$: 'vue/dist/vue.esm.js',
modules: path.resolve(rootPath, '../node_modules'),
images: `${srcPath}/assets/images`,
fonts: `${srcPath}/assets/fonts`,
variables: `${srcPath}/assets/sass/variables`,
tiptap: path.resolve(rootPath, '../packages/tiptap/src'),
'tiptap-commands': path.resolve(rootPath, '../packages/tiptap-commands/src'),
'tiptap-utils': path.resolve(rootPath, '../packages/tiptap-utils/src'),
'tiptap-models': path.resolve(rootPath, '../packages/tiptap-models/src'),
'tiptap-extensions': path.resolve(rootPath, '../packages/tiptap-extensions/src'),
},
modules: [
srcPath,
path.resolve(rootPath, '../node_modules'),
],
},
resolve: {
extensions: ['.js', '.scss', '.vue'],
alias: {
vue$: 'vue/dist/vue.esm.js',
modules: path.resolve(rootPath, '../node_modules'),
images: `${srcPath}/assets/images`,
fonts: `${srcPath}/assets/fonts`,
variables: `${srcPath}/assets/sass/variables`,
tiptap: path.resolve(rootPath, '../packages/tiptap/src'),
'tiptap-commands': path.resolve(rootPath, '../packages/tiptap-commands/src'),
'tiptap-utils': path.resolve(rootPath, '../packages/tiptap-utils/src'),
'tiptap-models': path.resolve(rootPath, '../packages/tiptap-models/src'),
'tiptap-extensions': path.resolve(rootPath, '../packages/tiptap-extensions/src'),
},
modules: [
srcPath,
path.resolve(rootPath, '../node_modules'),
],
},
devtool: ifDev('eval-source-map', 'source-map'),
devtool: ifDev('eval-source-map', 'source-map'),
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
exclude: [/node_modules/],
use: {
loader: ifDev('babel-loader?cacheDirectory=true', 'babel-loader'),
options: {
presets: [
'@babel/preset-env',
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
],
},
},
},
{
test: /\.css$/,
use: removeEmpty([
ifDev('vue-style-loader', MiniCssExtractPlugin.loader),
'css-loader',
'postcss-loader',
]),
},
{
test: /\.scss$/,
use: removeEmpty([
ifDev('vue-style-loader', MiniCssExtractPlugin.loader),
'css-loader',
'postcss-loader',
'sass-loader',
]),
},
{
test: /\.(png|jpe?g|gif|svg|ico)(\?.*)?$/,
use: {
loader: 'file-loader',
options: {
name: `assets/images/[name]${ifProd('.[hash]', '')}.[ext]`,
},
},
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: {
loader: 'file-loader',
options: {
name: `assets/fonts/[name]${ifProd('.[hash]', '')}.[ext]`,
},
},
},
],
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.js$/,
exclude: [/node_modules/],
use: {
loader: ifDev('babel-loader?cacheDirectory=true', 'babel-loader'),
options: {
presets: [
'@babel/preset-env',
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
],
},
},
},
{
test: /\.css$/,
use: removeEmpty([
ifDev('vue-style-loader', MiniCssExtractPlugin.loader),
'css-loader',
'postcss-loader',
]),
},
{
test: /\.scss$/,
use: removeEmpty([
ifDev('vue-style-loader', MiniCssExtractPlugin.loader),
'css-loader',
'postcss-loader',
'sass-loader',
]),
},
{
test: /\.(png|jpe?g|gif|svg|ico)(\?.*)?$/,
use: {
loader: 'file-loader',
options: {
name: `assets/images/[name]${ifProd('.[hash]', '')}.[ext]`,
},
},
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: {
loader: 'file-loader',
options: {
name: `assets/fonts/[name]${ifProd('.[hash]', '')}.[ext]`,
},
},
},
],
},
// splitting out the vendor
optimization: {
namedModules: true,
splitChunks: {
name: 'vendor',
minChunks: 2,
},
noEmitOnErrors: true,
// concatenateModules: true,
},
// splitting out the vendor
optimization: {
namedModules: true,
splitChunks: {
name: 'vendor',
minChunks: 2,
},
noEmitOnErrors: true,
// concatenateModules: true,
},
plugins: removeEmpty([
plugins: removeEmpty([
// create manifest file for server-side asset manipulation
new ManifestPlugin({
fileName: 'assets/manifest.json',
writeToFileEmit: true,
}),
// create manifest file for server-side asset manipulation
new ManifestPlugin({
fileName: 'assets/manifest.json',
writeToFileEmit: true,
}),
// define env
// new webpack.DefinePlugin({
// 'process.env': {},
// }),
// define env
// new webpack.DefinePlugin({
// 'process.env': {},
// }),
// copy static files
new CopyWebpackPlugin([
{
context: `${srcPath}/assets/static`,
from: { glob: '**/*', dot: false },
to: `${buildPath}/assets`,
},
{
context: `${srcPath}/assets/static`,
from: { glob: '**/*', dot: false },
to: `${buildPath}/assets/[path][name].[hash].[ext]`,
},
]),
// copy static files
new CopyWebpackPlugin([
{
context: `${srcPath}/assets/static`,
from: { glob: '**/*', dot: false },
to: `${buildPath}/assets`,
},
{
context: `${srcPath}/assets/static`,
from: { glob: '**/*', dot: false },
to: `${buildPath}/assets/[path][name].[hash].[ext]`,
},
]),
// enable hot reloading
ifDev(new webpack.HotModuleReplacementPlugin()),
// enable hot reloading
ifDev(new webpack.HotModuleReplacementPlugin()),
// html
new HtmlWebpackPlugin({
filename: 'index.html',
template: `${srcPath}/index.html`,
inject: true,
minify: ifProd({
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true,
}),
buildVersion: new Date().valueOf(),
chunksSortMode: 'none',
}),
// html
new HtmlWebpackPlugin({
filename: 'index.html',
template: `${srcPath}/index.html`,
inject: true,
minify: ifProd({
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true,
}),
buildVersion: new Date().valueOf(),
chunksSortMode: 'none',
}),
new VueLoaderPlugin(),
new VueLoaderPlugin(),
// create css files
ifProd(new MiniCssExtractPlugin({
filename: `assets/css/[name]${ifProd('.[hash]', '')}.css`,
chunkFilename: `assets/css/[name]${ifProd('.[hash]', '')}.css`,
})),
// create css files
ifProd(new MiniCssExtractPlugin({
filename: `assets/css/[name]${ifProd('.[hash]', '')}.css`,
chunkFilename: `assets/css/[name]${ifProd('.[hash]', '')}.css`,
})),
// minify css files
ifProd(new OptimizeCssAssetsPlugin({
cssProcessorOptions: {
reduceIdents: false,
autoprefixer: false,
zindex: false,
discardComments: {
removeAll: true,
},
},
})),
// minify css files
ifProd(new OptimizeCssAssetsPlugin({
cssProcessorOptions: {
reduceIdents: false,
autoprefixer: false,
zindex: false,
discardComments: {
removeAll: true,
},
},
})),
// svg icons
new SvgStore({
prefix: 'icon--',
svgoOptions: {
plugins: [
{ cleanupIDs: false },
{ collapseGroups: false },
{ removeTitle: true },
],
},
}),
// svg icons
new SvgStore({
prefix: 'icon--',
svgoOptions: {
plugins: [
{ cleanupIDs: false },
{ collapseGroups: false },
{ removeTitle: true },
],
},
}),
// image optimization
new ImageminWebpackPlugin({
optipng: ifDev(null, {
optimizationLevel: 3,
}),
jpegtran: ifDev(null, {
progressive: true,
quality: 80,
}),
svgo: ifDev(null, {
plugins: [
{ cleanupIDs: false },
{ removeViewBox: false },
{ removeUselessStrokeAndFill: false },
{ removeEmptyAttrs: false },
],
}),
}),
// image optimization
new ImageminWebpackPlugin({
optipng: ifDev(null, {
optimizationLevel: 3,
}),
jpegtran: ifDev(null, {
progressive: true,
quality: 80,
}),
svgo: ifDev(null, {
plugins: [
{ cleanupIDs: false },
{ removeViewBox: false },
{ removeUselessStrokeAndFill: false },
{ removeEmptyAttrs: false },
],
}),
}),
]),
]),
node: {
fs: 'empty',
},
node: {
fs: 'empty',
},
}

View File

@@ -9,7 +9,7 @@ import packagejson from '../../package.json'
const { version } = packagejson
const banner = `
/*!
/*!
* tiptap v${version}
* (c) ${new Date().getFullYear()} Scrumpy UG (limited liability)
* @license MIT
@@ -23,19 +23,19 @@ function genConfig(opts) {
input: {
input: opts.input,
plugins: [
flow(),
flow(),
node(),
cjs(),
vue({
css: true,
compileTemplate: true,
}),
vue({
css: true,
compileTemplate: true,
}),
replace({
__VERSION__: version,
}),
buble({
objectAssign: 'Object.assign',
}),
objectAssign: 'Object.assign',
}),
],
external(id) { return !/^[\.\/]/.test(id) },
},
@@ -44,7 +44,7 @@ function genConfig(opts) {
format: opts.format,
banner,
name: 'tiptap',
},
},
}
if (opts.env) {

View File

@@ -1,7 +1,7 @@
<template>
<a class="ad" href="https://scrumpy.io/" target="_blank">
<img class="ad__image" src="https://drop.philipp-kuehn.com/api98lPyuw.png" alt="Scrumpy. Agile Planning, Made Simple. Get Started." />
</a>
<a class="ad" href="https://scrumpy.io/" target="_blank">
<img class="ad__image" src="https://drop.philipp-kuehn.com/api98lPyuw.png" alt="Scrumpy. Agile Planning, Made Simple. Get Started." />
</a>
</template>
<style lang="scss" src="./style.scss" scoped></style>

View File

@@ -1,42 +1,42 @@
@import "~variables";
.ad {
display: block;
padding: 1rem;
transition: 0.2s transform;
margin: 3rem auto 0 auto;
width: 15rem;
display: block;
padding: 1rem;
transition: 0.2s transform;
margin: 3rem auto 0 auto;
width: 15rem;
@media (min-width: 1020px) {
position: fixed;
left: 0;
bottom: 0;
margin-top: 0;
}
@media (min-width: 1020px) {
position: fixed;
left: 0;
bottom: 0;
margin-top: 0;
}
&__image {
display: block;
width: 100%;
height: auto;
border-radius: 5px;
overflow: hidden;
transition: 0.2s box-shadow;
box-shadow:
0 2px 4px 0 rgba(black, 0.05),
0 2px 10px 0 rgba(black, 0.07)
;
}
&__image {
display: block;
width: 100%;
height: auto;
border-radius: 5px;
overflow: hidden;
transition: 0.2s box-shadow;
box-shadow:
0 2px 4px 0 rgba(black, 0.05),
0 2px 10px 0 rgba(black, 0.07)
;
}
&:hover {
transform: translateY(-5px);
}
&:hover {
transform: translateY(-5px);
}
&:hover &__image {
box-shadow:
0 2px 1px 0 rgba(black, 0.07),
0 5px 20px 0 rgba(black, 0.06),
0 8px 40px 0 rgba(black, 0.04)
;
}
&:hover &__image {
box-shadow:
0 2px 1px 0 rgba(black, 0.07),
0 5px 20px 0 rgba(black, 0.06),
0 8px 40px 0 rgba(black, 0.04)
;
}
}

View File

@@ -2,8 +2,8 @@
.page {
&__content {
padding: 4rem 1rem;
&__content {
padding: 4rem 1rem;
}
&__footer {

View File

@@ -1,17 +1,17 @@
<template>
<div class="hero">
<div class="hero__inner">
<svg class="hero__logo" xmlns="http://www.w3.org/2000/svg" width="150" height="147" viewBox="0 0 150 147">
<path fill-rule="evenodd" d="M26.078305,62.3613338 C25.7098682,65.5071869 26.7651499,68.7890473 29.2305323,71.1698395 C31.8780402,73.7265081 35.5095868,74.6264505 38.835659,73.8909922 C40.1685862,69.1852287 42.5994079,65.0738098 46.1185082,61.5792271 C46.7063387,60.995492 47.6560804,60.9988119 48.2398155,61.5866425 C48.8235507,62.1744731 48.8202307,63.1242147 48.2324001,63.7079499 C45.1597213,66.7592214 43.0224884,70.3159362 41.8118122,74.3988864 C42.3036563,76.1343979 43.2448226,77.7695377 44.6376666,79.1145915 C48.8244385,83.1577101 55.4712308,83.06028 59.4841755,78.9047541 C61.5413401,76.7744978 62.5014774,73.9953585 62.3942536,71.2471292 C64.9641607,68.2381852 67.396202,63.4176619 68.4179079,58.0024937 C80.3874868,49.1562654 93.8426151,32.568523 104.371445,5.59032072 C93.9174954,41.5317695 86.0252453,64.5167382 80.6946894,74.5452392 C78.8496917,78.0162771 77.2336561,81.3879265 75.8950435,84.6284349 C70.1999494,87.1232884 65.6357246,91.845809 62.9452121,95.9367699 C59.846433,96.4572059 57.0236331,98.34297 55.4397322,101.321854 C52.7260669,106.425517 54.6851579,112.773997 59.824176,115.506462 C61.0978472,116.183685 62.4470058,116.571386 63.7975953,116.696179 C64.912262,112.255705 67.1815584,108.483174 70.5954127,105.413494 C71.2114278,104.859583 72.15984,104.909929 72.7137505,105.525944 C73.267661,106.141959 73.2173152,107.090371 72.6013001,107.644282 C68.8800506,110.990367 66.7377922,115.262277 66.1547346,120.528599 C66.6676865,123.65414 68.5866912,126.517989 71.6128022,128.127001 C74.862469,129.854879 78.6014006,129.718966 81.5959091,128.095201 C81.5801274,123.204349 82.7835226,118.582195 85.2030509,114.25301 C85.6072107,113.52986 86.5210761,113.271267 87.2442264,113.675427 C87.9673767,114.079586 88.2259696,114.993452 87.8218099,115.716602 C85.7092106,119.496609 84.635134,123.504634 84.5967664,127.763114 C85.5479283,129.295814 86.9033354,130.608181 88.6129606,131.517205 C93.7519787,134.249669 100.11443,132.323909 102.826502,127.223244 C104.216797,124.608478 104.373706,121.672349 103.513121,119.060136 C104.520365,116.84997 105.289548,113.998031 105.615371,110.855954 C119.212334,100.723957 134.689881,80.6105128 145.607087,46.648385 C148.450672,54.5670114 150,63.1025732 150,72 C150,113.421356 116.421356,147 75,147 C33.5786438,147 0,113.421356 0,72 C0,38.4830506 21.9858616,10.1011743 52.3224198,0.48953606 C48.9402683,10.0685574 46.0643582,17.0871252 43.6946894,21.5452392 C41.7000425,25.2978155 39.9730067,28.9342266 38.5748172,32.4143498 C35.1216793,33.8554208 32.0687555,35.8637935 29.77154,37.837492 C26.6493508,37.4836279 23.4161144,38.5182716 21.0724792,40.9451768 C17.0571768,45.1031442 17.1904975,51.7456943 21.3772695,55.7888129 C22.4149324,56.7908723 23.6049613,57.5354331 24.8688331,58.0276644 C27.16428,54.0664499 30.3855184,51.0655629 34.5132451,49.0557819 C35.2580753,48.6931257 36.1558704,49.0029389 36.5185265,49.7477691 C36.8811827,50.4925993 36.5713695,51.3903944 35.8265393,51.7530505 C31.3271397,53.9437983 28.0903713,57.4597339 26.078305,62.3613338 Z"/>
</svg>
<h1>
tiptap a renderless rich-text editor for Vue.js
</h1>
<p>
This editor is based on <a href="https://prosemirror.net">Prosemirror</a>, <em>fully extendable</em> and renderless. You can easily add custom nodes as <strong>Vue components</strong>.
</p>
</div>
</div>
<div class="hero">
<div class="hero__inner">
<svg class="hero__logo" xmlns="http://www.w3.org/2000/svg" width="150" height="147" viewBox="0 0 150 147">
<path fill-rule="evenodd" d="M26.078305,62.3613338 C25.7098682,65.5071869 26.7651499,68.7890473 29.2305323,71.1698395 C31.8780402,73.7265081 35.5095868,74.6264505 38.835659,73.8909922 C40.1685862,69.1852287 42.5994079,65.0738098 46.1185082,61.5792271 C46.7063387,60.995492 47.6560804,60.9988119 48.2398155,61.5866425 C48.8235507,62.1744731 48.8202307,63.1242147 48.2324001,63.7079499 C45.1597213,66.7592214 43.0224884,70.3159362 41.8118122,74.3988864 C42.3036563,76.1343979 43.2448226,77.7695377 44.6376666,79.1145915 C48.8244385,83.1577101 55.4712308,83.06028 59.4841755,78.9047541 C61.5413401,76.7744978 62.5014774,73.9953585 62.3942536,71.2471292 C64.9641607,68.2381852 67.396202,63.4176619 68.4179079,58.0024937 C80.3874868,49.1562654 93.8426151,32.568523 104.371445,5.59032072 C93.9174954,41.5317695 86.0252453,64.5167382 80.6946894,74.5452392 C78.8496917,78.0162771 77.2336561,81.3879265 75.8950435,84.6284349 C70.1999494,87.1232884 65.6357246,91.845809 62.9452121,95.9367699 C59.846433,96.4572059 57.0236331,98.34297 55.4397322,101.321854 C52.7260669,106.425517 54.6851579,112.773997 59.824176,115.506462 C61.0978472,116.183685 62.4470058,116.571386 63.7975953,116.696179 C64.912262,112.255705 67.1815584,108.483174 70.5954127,105.413494 C71.2114278,104.859583 72.15984,104.909929 72.7137505,105.525944 C73.267661,106.141959 73.2173152,107.090371 72.6013001,107.644282 C68.8800506,110.990367 66.7377922,115.262277 66.1547346,120.528599 C66.6676865,123.65414 68.5866912,126.517989 71.6128022,128.127001 C74.862469,129.854879 78.6014006,129.718966 81.5959091,128.095201 C81.5801274,123.204349 82.7835226,118.582195 85.2030509,114.25301 C85.6072107,113.52986 86.5210761,113.271267 87.2442264,113.675427 C87.9673767,114.079586 88.2259696,114.993452 87.8218099,115.716602 C85.7092106,119.496609 84.635134,123.504634 84.5967664,127.763114 C85.5479283,129.295814 86.9033354,130.608181 88.6129606,131.517205 C93.7519787,134.249669 100.11443,132.323909 102.826502,127.223244 C104.216797,124.608478 104.373706,121.672349 103.513121,119.060136 C104.520365,116.84997 105.289548,113.998031 105.615371,110.855954 C119.212334,100.723957 134.689881,80.6105128 145.607087,46.648385 C148.450672,54.5670114 150,63.1025732 150,72 C150,113.421356 116.421356,147 75,147 C33.5786438,147 0,113.421356 0,72 C0,38.4830506 21.9858616,10.1011743 52.3224198,0.48953606 C48.9402683,10.0685574 46.0643582,17.0871252 43.6946894,21.5452392 C41.7000425,25.2978155 39.9730067,28.9342266 38.5748172,32.4143498 C35.1216793,33.8554208 32.0687555,35.8637935 29.77154,37.837492 C26.6493508,37.4836279 23.4161144,38.5182716 21.0724792,40.9451768 C17.0571768,45.1031442 17.1904975,51.7456943 21.3772695,55.7888129 C22.4149324,56.7908723 23.6049613,57.5354331 24.8688331,58.0276644 C27.16428,54.0664499 30.3855184,51.0655629 34.5132451,49.0557819 C35.2580753,48.6931257 36.1558704,49.0029389 36.5185265,49.7477691 C36.8811827,50.4925993 36.5713695,51.3903944 35.8265393,51.7530505 C31.3271397,53.9437983 28.0903713,57.4597339 26.078305,62.3613338 Z"/>
</svg>
<h1>
tiptap a renderless rich-text editor for Vue.js
</h1>
<p>
This editor is based on <a href="https://prosemirror.net">Prosemirror</a>, <em>fully extendable</em> and renderless. You can easily add custom nodes as <strong>Vue components</strong>.
</p>
</div>
</div>
</template>
<style lang="scss" src="./style.scss" scoped></style>

View File

@@ -2,23 +2,23 @@
.hero {
background-color: $color-black;
color: $color-white;
text-align: center;
padding: 3rem 1rem;
background-color: $color-black;
color: $color-white;
text-align: center;
padding: 3rem 1rem;
&__inner {
margin: 0 auto;
max-width: 30rem;
}
&__inner {
margin: 0 auto;
max-width: 30rem;
}
&__logo {
width: 4rem;
height: 4rem;
&__logo {
width: 4rem;
height: 4rem;
path {
fill: $color-white;
}
}
path {
fill: $color-white;
}
}
}

View File

@@ -1,57 +1,57 @@
<template>
<div class="icon" :class="[`icon--${name}`, `icon--${size}`, { 'has-align-fix': fixAlign }]">
<svg class="icon__svg">
<use xmlns:xlink="http://www.w3.org/1999/xlink" :xlink:href="'#icon--' + name"></use>
</svg>
</div>
<div class="icon" :class="[`icon--${name}`, `icon--${size}`, { 'has-align-fix': fixAlign }]">
<svg class="icon__svg">
<use xmlns:xlink="http://www.w3.org/1999/xlink" :xlink:href="'#icon--' + name"></use>
</svg>
</div>
</template>
<script>
export default {
props: {
name: {},
size: {
default: 'normal',
},
modifier: {
default: null,
},
fixAlign: {
default: true,
},
},
props: {
name: {},
size: {
default: 'normal',
},
modifier: {
default: null,
},
fixAlign: {
default: true,
},
},
}
</script>
<style lang="scss" scoped>
.icon {
position: relative;
display: inline-block;
vertical-align: middle;
width: 0.8rem;
height: 0.8rem;
margin: 0 .3rem;
top: -.05rem;
fill: currentColor;
position: relative;
display: inline-block;
vertical-align: middle;
width: 0.8rem;
height: 0.8rem;
margin: 0 .3rem;
top: -.05rem;
fill: currentColor;
// &.has-align-fix {
// top: -.1rem;
// }
// &.has-align-fix {
// top: -.1rem;
// }
&__svg {
display: inline-block;
vertical-align: top;
width: 100%;
height: 100%;
}
&__svg {
display: inline-block;
vertical-align: top;
width: 100%;
height: 100%;
}
&:first-child {
margin-left: 0;
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
&:last-child {
margin-right: 0;
}
}
@@ -59,16 +59,16 @@ export default {
body > svg,
.icon use > svg,
symbol {
path,
rect,
circle,
g {
fill: currentColor;
stroke: none;
}
path,
rect,
circle,
g {
fill: currentColor;
stroke: none;
}
*[d="M0 0h24v24H0z"] {
display: none;
}
*[d="M0 0h24v24H0z"] {
display: none;
}
}
</style>

View File

@@ -1,29 +1,29 @@
<template>
<div class="navigation">
<div class="navigation">
<h1 class="navigation__logo">
tiptap <span class="navigation__beta">beta</span>
</h1>
<h1 class="navigation__logo">
tiptap <span class="navigation__beta">beta</span>
</h1>
<div>
<a class="navigation__link" href="https://github.com/heyscrumpy/tiptap/blob/master/CONTRIBUTING.md" target="_blank">
Contribute
</a>
<a class="navigation__github-link" href="https://github.com/heyscrumpy/tiptap" target="_blank">
<icon class="navigation__icon" name="github" />
</a>
</div>
<div>
<a class="navigation__link" href="https://github.com/heyscrumpy/tiptap/blob/master/CONTRIBUTING.md" target="_blank">
Contribute
</a>
<a class="navigation__github-link" href="https://github.com/heyscrumpy/tiptap" target="_blank">
<icon class="navigation__icon" name="github" />
</a>
</div>
</div>
</div>
</template>
<script>
import Icon from 'Components/Icon'
export default {
components: {
Icon,
},
components: {
Icon,
},
}
</script>

View File

@@ -2,54 +2,54 @@
.navigation {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background-color: $color-black;
color: $color-white;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background-color: $color-black;
color: $color-white;
&__logo {
font-size: 1.1rem;
font-weight: bold;
margin: 0;
}
&__logo {
font-size: 1.1rem;
font-weight: bold;
margin: 0;
}
&__icon {
width: 1.5rem;
height: 1.5rem;
}
&__icon {
width: 1.5rem;
height: 1.5rem;
}
&__beta {
display: inline-block;
vertical-align: middle;
background-color: $color-white;
color: $color-black;
font-size: 0.6rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.05rem;
padding: 0.1rem 0.2rem;
border-radius: 5px;
}
&__beta {
display: inline-block;
vertical-align: middle;
background-color: $color-white;
color: $color-black;
font-size: 0.6rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.05rem;
padding: 0.1rem 0.2rem;
border-radius: 5px;
}
&__link {
display: inline-block;
color: rgba($color-white, 0.5);
text-decoration: none;
font-weight: bold;
font-size: 0.9rem;
padding: 0.1rem 0.5rem;
border-radius: 3px;
&__link {
display: inline-block;
color: rgba($color-white, 0.5);
text-decoration: none;
font-weight: bold;
font-size: 0.9rem;
padding: 0.1rem 0.5rem;
border-radius: 3px;
&:hover {
color: $color-white;
&:hover {
color: $color-white;
background-color: rgba($color-white, 0.1);
}
}
}
&__github-link {
margin-left: 0.5rem;
}
&__github-link {
margin-left: 0.5rem;
}
}

View File

@@ -1,209 +1,209 @@
<template>
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands, isActive }">
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands, isActive }">
<button
class="menubar__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('strike') }"
@click="commands.strike"
>
<icon name="strike" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('strike') }"
@click="commands.strike"
>
<icon name="strike" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('underline') }"
@click="commands.underline"
>
<icon name="underline" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('underline') }"
@click="commands.underline"
>
<icon name="underline" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph') }"
@click="commands.paragraph"
>
<icon name="paragraph" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph') }"
@click="commands.paragraph"
>
<icon name="paragraph" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bullet_list') }"
@click="commands.bullet_list"
>
<icon name="ul" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bullet_list') }"
@click="commands.bullet_list"
>
<icon name="ul" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('ordered_list') }"
@click="commands.ordered_list"
>
<icon name="ol" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('ordered_list') }"
@click="commands.ordered_list"
>
<icon name="ol" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('blockquote') }"
@click="commands.blockquote"
>
<icon name="quote" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('blockquote') }"
@click="commands.blockquote"
>
<icon name="quote" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code_block') }"
@click="commands.code_block"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code_block') }"
@click="commands.code_block"
>
<icon name="code" />
</button>
<button
class="menubar__button"
@click="commands.undo"
>
<icon name="undo" />
</button>
<button
class="menubar__button"
@click="commands.undo"
>
<icon name="undo" />
</button>
<button
class="menubar__button"
@click="commands.redo"
>
<icon name="redo" />
</button>
<button
class="menubar__button"
@click="commands.redo"
>
<icon name="redo" />
</button>
</div>
</menu-bar>
</div>
</menu-bar>
<editor-content class="editor__content" :editor="editor" />
</div>
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, MenuBar } from 'tiptap'
import {
Blockquote,
CodeBlock,
HardBreak,
Heading,
OrderedList,
BulletList,
ListItem,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
Strike,
Underline,
History,
Blockquote,
CodeBlock,
HardBreak,
Heading,
OrderedList,
BulletList,
ListItem,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
Strike,
Underline,
History,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new Strike(),
new Underline(),
new History(),
],
content: `
<h2>
Hi there,
</h2>
<p>
this is a very <em>basic</em> example of tiptap.
</p>
<pre><code>body { display: none; }</code></pre>
<ul>
<li>
A regular list
</li>
<li>
With regular items
</li>
</ul>
<blockquote>
It's amazing 👏
<br />
mom
</blockquote>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new Strike(),
new Underline(),
new History(),
],
content: `
<h2>
Hi there,
</h2>
<p>
this is a very <em>basic</em> example of tiptap.
</p>
<pre><code>body { display: none; }</code></pre>
<ul>
<li>
A regular list
</li>
<li>
With regular items
</li>
</ul>
<blockquote>
It's amazing 👏
<br />
mom
</blockquote>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>

View File

@@ -1,136 +1,136 @@
<template>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import { Editor, EditorContent } from 'tiptap'
import {
CodeBlockHighlight,
HardBreak,
Heading,
Bold,
Code,
Italic,
CodeBlockHighlight,
HardBreak,
Heading,
Bold,
Code,
Italic,
} from 'tiptap-extensions'
import javascript from 'highlight.js/lib/languages/javascript'
import css from 'highlight.js/lib/languages/css'
import {
JavaScriptExample,
CSSExample,
ExplicitImportExample,
JavaScriptExample,
CSSExample,
ExplicitImportExample,
} from './examples'
export default {
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
extensions: [
new CodeBlockHighlight({
languages: {
javascript,
css,
},
}),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Bold(),
new Code(),
new Italic(),
],
content: `
<h2>
Code Highlighting
</h2>
<p>
These are code blocks with <strong>automatic syntax highlighting</strong> based on highlight.js.
</p>
<pre><code>${JavaScriptExample}</code></pre>
<pre><code>${CSSExample}</code></pre>
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
extensions: [
new CodeBlockHighlight({
languages: {
javascript,
css,
},
}),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Bold(),
new Code(),
new Italic(),
],
content: `
<h2>
Code Highlighting
</h2>
<p>
These are code blocks with <strong>automatic syntax highlighting</strong> based on highlight.js.
</p>
<pre><code>${JavaScriptExample}</code></pre>
<pre><code>${CSSExample}</code></pre>
<p>
Note: tiptap doesn't import syntax highlighting language definitions from highlight.js. You
<strong>must</strong> import them and initialize the extension with all languages you want to support:
</p>
<pre><code>${ExplicitImportExample}</code></pre>
`,
}),
}
},
<p>
Note: tiptap doesn't import syntax highlighting language definitions from highlight.js. You
<strong>must</strong> import them and initialize the extension with all languages you want to support:
</p>
<pre><code>${ExplicitImportExample}</code></pre>
`,
}),
}
},
}
</script>
<style lang="scss">
pre {
&::before {
content: attr(data-language);
text-transform: uppercase;
display: block;
text-align: right;
font-weight: bold;
font-size: 0.6rem;
}
&::before {
content: attr(data-language);
text-transform: uppercase;
display: block;
text-align: right;
font-weight: bold;
font-size: 0.6rem;
}
code {
code {
.hljs-comment,
.hljs-quote {
color: #999999;
}
.hljs-comment,
.hljs-quote {
color: #999999;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f2777a;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f2777a;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #f99157;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #f99157;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #99cc99;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #99cc99;
}
.hljs-title,
.hljs-section {
color: #ffcc66;
}
.hljs-title,
.hljs-section {
color: #ffcc66;
}
.hljs-keyword,
.hljs-selector-tag {
color: #6699cc;
}
.hljs-keyword,
.hljs-selector-tag {
color: #6699cc;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
.hljs-strong {
font-weight: 700;
}
}
}
</style>

View File

@@ -2,57 +2,57 @@ import { Node } from 'tiptap'
export default class Iframe extends Node {
get name() {
return 'iframe'
}
get name() {
return 'iframe'
}
get schema() {
return {
attrs: {
src: {
default: null,
},
},
group: 'block',
selectable: false,
parseDOM: [{
tag: 'iframe',
getAttrs: dom => ({
src: dom.getAttribute('src'),
}),
}],
toDOM: node => ['iframe', {
src: node.attrs.src,
frameborder: 0,
allowfullscreen: 'true',
}],
}
}
get schema() {
return {
attrs: {
src: {
default: null,
},
},
group: 'block',
selectable: false,
parseDOM: [{
tag: 'iframe',
getAttrs: dom => ({
src: dom.getAttribute('src'),
}),
}],
toDOM: node => ['iframe', {
src: node.attrs.src,
frameborder: 0,
allowfullscreen: 'true',
}],
}
}
get view() {
return {
props: ['node', 'updateAttrs', 'editable'],
data() {
return {
url: this.node.attrs.src,
}
},
methods: {
onChange(event) {
this.url = event.target.value
get view() {
return {
props: ['node', 'updateAttrs', 'editable'],
data() {
return {
url: this.node.attrs.src,
}
},
methods: {
onChange(event) {
this.url = event.target.value
this.updateAttrs({
src: this.url,
})
},
},
template: `
<div class="iframe">
<iframe class="iframe__embed" :src="url"></iframe>
<input class="iframe__input" type="text" :value="url" @input="onChange" v-if="editable" />
</div>
`,
}
}
this.updateAttrs({
src: this.url,
})
},
},
template: `
<div class="iframe">
<iframe class="iframe__embed" :src="url"></iframe>
<input class="iframe__input" type="text" :value="url" @input="onChange" v-if="editable" />
</div>
`,
}
}
}

View File

@@ -1,51 +1,51 @@
<template>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import { Editor, EditorContent } from 'tiptap'
import {
HardBreak,
Heading,
Bold,
Italic,
History,
HardBreak,
Heading,
Bold,
Italic,
History,
} from 'tiptap-extensions'
import Iframe from './Iframe.js'
export default {
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Bold(),
new Italic(),
new History(),
// custom extension
new Iframe(),
],
content: `
<h2>
Embeds
</h2>
<p>
This is an example of a custom iframe node. This iframe is rendered as a <strong>vue component</strong>. This makes it possible to render the input below to change its source.
</p>
<iframe src="https://www.youtube.com/embed/XIMLoLxmTDw" frameborder="0" allowfullscreen></iframe>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Bold(),
new Italic(),
new History(),
// custom extension
new Iframe(),
],
content: `
<h2>
Embeds
</h2>
<p>
This is an example of a custom iframe node. This iframe is rendered as a <strong>vue component</strong>. This makes it possible to render the input below to change its source.
</p>
<iframe src="https://www.youtube.com/embed/XIMLoLxmTDw" frameborder="0" allowfullscreen></iframe>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>
@@ -53,20 +53,20 @@ export default {
@import "~variables";
.iframe {
&__embed {
width: 100%;
height: 15rem;
border: 0;
}
&__embed {
width: 100%;
height: 15rem;
border: 0;
}
&__input {
display: block;
width: 100%;
font: inherit;
border: 0;
border-radius: 5px;
background-color: rgba($color-black, 0.1);
padding: 0.3rem 0.5rem;
}
&__input {
display: block;
width: 100%;
font: inherit;
border: 0;
border-radius: 5px;
background-color: rgba($color-black, 0.1);
padding: 0.3rem 0.5rem;
}
}
</style>

View File

@@ -1,203 +1,203 @@
<template>
<div>
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands, isActive }">
<div>
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands, isActive }">
<button
class="menubar__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph') }"
@click="commands.paragraph"
>
<icon name="paragraph" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph') }"
@click="commands.paragraph"
>
<icon name="paragraph" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bullet_list') }"
@click="commands.bullet_list"
>
<icon name="ul" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bullet_list') }"
@click="commands.bullet_list"
>
<icon name="ul" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('ordered_list') }"
@click="commands.ordered_list"
>
<icon name="ol" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('ordered_list') }"
@click="commands.ordered_list"
>
<icon name="ol" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code_block') }"
@click="commands.code_block"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code_block') }"
@click="commands.code_block"
>
<icon name="code" />
</button>
</div>
</menu-bar>
</div>
</menu-bar>
<editor-content class="editor__content" :editor="editor" />
<editor-content class="editor__content" :editor="editor" />
</div>
</div>
<div class="actions">
<button class="button" @click="clearContent">
Clear Content
</button>
<button class="button" @click="setContent">
Set Content
</button>
</div>
<div class="actions">
<button class="button" @click="clearContent">
Clear Content
</button>
<button class="button" @click="setContent">
Set Content
</button>
</div>
<div class="export">
<h3>JSON</h3>
<pre><code v-html="json"></code></pre>
<div class="export">
<h3>JSON</h3>
<pre><code v-html="json"></code></pre>
<h3>HTML</h3>
<pre><code>{{ html }}</code></pre>
</div>
</div>
<h3>HTML</h3>
<pre><code>{{ html }}</code></pre>
</div>
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, MenuBar } from 'tiptap'
import {
Blockquote,
CodeBlock,
HardBreak,
Heading,
OrderedList,
BulletList,
ListItem,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
History,
Blockquote,
CodeBlock,
HardBreak,
Heading,
OrderedList,
BulletList,
ListItem,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
History,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new History(),
],
content: `
<h2>
Export HTML or JSON
</h2>
<p>
You are able to export your data as <code>HTML</code> or <code>JSON</code>. To pass <code>HTML</code> to the editor use the <code>content</code> slot. To pass <code>JSON</code> to the editor use the <code>doc</code> prop.
</p>
`,
onUpdate: ({ getJSON, getHTML }) => {
this.json = getJSON()
this.html = getHTML()
},
}),
json: 'Update content to see changes',
html: 'Update content to see changes',
}
},
methods: {
clearContent() {
this.editor.clearContent(true)
this.editor.focus()
},
setContent() {
// you can pass a json document
this.editor.setContent({
type: 'doc',
content: [{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is some inserted text. 👋',
},
],
}],
}, true)
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new History(),
],
content: `
<h2>
Export HTML or JSON
</h2>
<p>
You are able to export your data as <code>HTML</code> or <code>JSON</code>. To pass <code>HTML</code> to the editor use the <code>content</code> slot. To pass <code>JSON</code> to the editor use the <code>doc</code> prop.
</p>
`,
onUpdate: ({ getJSON, getHTML }) => {
this.json = getJSON()
this.html = getHTML()
},
}),
json: 'Update content to see changes',
html: 'Update content to see changes',
}
},
methods: {
clearContent() {
this.editor.clearContent(true)
this.editor.focus()
},
setContent() {
// you can pass a json document
this.editor.setContent({
type: 'doc',
content: [{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is some inserted text. 👋',
},
],
}],
}, true)
// HTML string is also supported
// this.editor.setContent('<p>This is some inserted text. 👋</p>')
// HTML string is also supported
// this.editor.setContent('<p>This is some inserted text. 👋</p>')
this.editor.focus()
},
},
this.editor.focus()
},
},
}
</script>
@@ -205,27 +205,27 @@ export default {
@import "~variables";
.actions {
max-width: 30rem;
margin: 0 auto 2rem auto;
max-width: 30rem;
margin: 0 auto 2rem auto;
}
.export {
max-width: 30rem;
margin: 0 auto 2rem auto;
max-width: 30rem;
margin: 0 auto 2rem auto;
pre {
padding: 1rem;
border-radius: 5px;
font-size: 0.8rem;
font-weight: bold;
background: rgba($color-black, 0.05);
color: rgba($color-black, 0.8);
}
pre {
padding: 1rem;
border-radius: 5px;
font-size: 0.8rem;
font-weight: bold;
background: rgba($color-black, 0.05);
color: rgba($color-black, 0.8);
}
code {
display: block;
white-space: pre-wrap;
}
code {
display: block;
white-space: pre-wrap;
}
}
</style>

View File

@@ -1,132 +1,132 @@
<template>
<div class="editor">
<floating-menu :editor="editor">
<div
slot-scope="{ commands, isActive, menu }"
class="editor__floating-menu"
:class="{ 'is-active': menu.isActive }"
:style="`top: ${menu.top}px`"
>
<div class="editor">
<floating-menu :editor="editor">
<div
slot-scope="{ commands, isActive, menu }"
class="editor__floating-menu"
:class="{ 'is-active': menu.isActive }"
:style="`top: ${menu.top}px`"
>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bullet_list') }"
@click="commands.bullet_list"
>
<icon name="ul" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bullet_list') }"
@click="commands.bullet_list"
>
<icon name="ul" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('ordered_list') }"
@click="commands.ordered_list"
>
<icon name="ol" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('ordered_list') }"
@click="commands.ordered_list"
>
<icon name="ol" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('blockquote') }"
@click="commands.blockquote"
>
<icon name="quote" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('blockquote') }"
@click="commands.blockquote"
>
<icon name="quote" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code_block') }"
@click="commands.code_block"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code_block') }"
@click="commands.code_block"
>
<icon name="code" />
</button>
</div>
</floating-menu>
</div>
</floating-menu>
<editor-content class="editor__content" :editor="editor" />
</div>
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, FloatingMenu } from 'tiptap'
import {
Blockquote,
BulletList,
CodeBlock,
HardBreak,
Heading,
ListItem,
OrderedList,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
History,
Blockquote,
BulletList,
CodeBlock,
HardBreak,
Heading,
ListItem,
OrderedList,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
History,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
FloatingMenu,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new History(),
],
content: `
<h2>
Floating Menu
</h2>
<p>
This is an example of a medium-like editor. Enter a new line and some buttons will appear.
</p>
`,
}),
}
},
components: {
EditorContent,
FloatingMenu,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new History(),
],
content: `
<h2>
Floating Menu
</h2>
<p>
This is an example of a medium-like editor. Enter a new line and some buttons will appear.
</p>
`,
}),
}
},
}
</script>
@@ -135,20 +135,20 @@ export default {
.editor {
position: relative;
position: relative;
&__floating-menu {
position: absolute;
margin-top: -0.25rem;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s, visibility 0.2s;
&__floating-menu {
position: absolute;
margin-top: -0.25rem;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s, visibility 0.2s;
&.is-active {
opacity: 1;
visibility: visible;
}
}
&.is-active {
opacity: 1;
visibility: visible;
}
}
}
</style>

View File

@@ -1,185 +1,185 @@
<template>
<div class="editor">
<menu-bar :editor="editor">
<div
class="menubar is-hidden"
:class="{ 'is-focused': focused }"
slot-scope="{ commands, isActive, focused }"
>
<div class="editor">
<menu-bar :editor="editor">
<div
class="menubar is-hidden"
:class="{ 'is-focused': focused }"
slot-scope="{ commands, isActive, focused }"
>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('strike') }"
@click="commands.strike"
>
<icon name="strike" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('strike') }"
@click="commands.strike"
>
<icon name="strike" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('underline') }"
@click="commands.underline"
>
<icon name="underline" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('underline') }"
@click="commands.underline"
>
<icon name="underline" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph') }"
@click="commands.paragraph"
>
<icon name="paragraph" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph') }"
@click="commands.paragraph"
>
<icon name="paragraph" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
H1
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
H2
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('heading', { level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
H3
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bullet_list') }"
@click="commands.bullet_list"
>
<icon name="ul" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bullet_list') }"
@click="commands.bullet_list"
>
<icon name="ul" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('ordered_list') }"
@click="commands.ordered_list"
>
<icon name="ol" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('ordered_list') }"
@click="commands.ordered_list"
>
<icon name="ol" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('blockquote') }"
@click="commands.blockquote"
>
<icon name="quote" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('blockquote') }"
@click="commands.blockquote"
>
<icon name="quote" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code_block') }"
@click="commands.code_block"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code_block') }"
@click="commands.code_block"
>
<icon name="code" />
</button>
</div>
</menu-bar>
</div>
</menu-bar>
<editor-content class="editor__content" :editor="editor" />
</div>
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, MenuBar } from 'tiptap'
import {
Blockquote,
BulletList,
CodeBlock,
HardBreak,
Heading,
ListItem,
OrderedList,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
Strike,
Underline,
History,
Blockquote,
BulletList,
CodeBlock,
HardBreak,
Heading,
ListItem,
OrderedList,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
Strike,
Underline,
History,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new Strike(),
new Underline(),
new History(),
],
content: `
<h2>
Hiding Menu Bar
</h2>
<p>
Click into this text to see the menu. Click outside and the menu will disappear. It's like magic.
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new Strike(),
new Underline(),
new History(),
],
content: `
<h2>
Hiding Menu Bar
</h2>
<p>
Click into this text to see the menu. Click outside and the menu will disappear. It's like magic.
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>

View File

@@ -1,68 +1,68 @@
<template>
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands }">
<button
class="menubar__button"
@click="showImagePrompt(commands.image)"
>
<icon name="image" />
</button>
</div>
</menu-bar>
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands }">
<button
class="menubar__button"
@click="showImagePrompt(commands.image)"
>
<icon name="image" />
</button>
</div>
</menu-bar>
<editor-content class="editor__content" :editor="editor" />
</div>
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, MenuBar } from 'tiptap'
import {
HardBreak,
Heading,
Image,
Bold,
Code,
Italic,
HardBreak,
Heading,
Image,
Bold,
Code,
Italic,
} from 'tiptap-extensions'
export default {
components: {
Icon,
EditorContent,
MenuBar,
},
data() {
return {
editor: new Editor({
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Image(),
new Bold(),
new Code(),
new Italic(),
],
content: `
<h2>
Images
</h2>
<p>
This is basic example of implementing images. Try to drop new images here. Reordering also works.
</p>
<img src="https://ljdchost.com/8I2DeFn.gif" />
`,
}),
}
},
methods: {
showImagePrompt(command) {
const src = prompt('Enter the url of your image here')
if (src !== null) {
command({ src })
}
},
},
components: {
Icon,
EditorContent,
MenuBar,
},
data() {
return {
editor: new Editor({
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Image(),
new Bold(),
new Code(),
new Italic(),
],
content: `
<h2>
Images
</h2>
<p>
This is basic example of implementing images. Try to drop new images here. Reordering also works.
</p>
<img src="https://ljdchost.com/8I2DeFn.gif" />
`,
}),
}
},
methods: {
showImagePrompt(command) {
const src = prompt('Enter the url of your image here')
if (src !== null) {
command({ src })
}
},
},
}
</script>

View File

@@ -1,113 +1,113 @@
<template>
<div class="editor">
<menu-bubble class="menububble" :editor="editor">
<div
slot-scope="{ commands, isActive, markAttrs, menu }"
class="menububble"
:class="{ 'is-active': menu.isActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<div class="editor">
<menu-bubble class="menububble" :editor="editor">
<div
slot-scope="{ commands, isActive, markAttrs, menu }"
class="menububble"
:class="{ 'is-active': menu.isActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<form class="menububble__form" v-if="linkMenuIsActive" @submit.prevent="setLinkUrl(commands.link, linkUrl)">
<input class="menububble__input" type="text" v-model="linkUrl" placeholder="https://" ref="linkInput" @keydown.esc="hideLinkMenu"/>
<button class="menububble__button" @click="setLinkUrl(commands.link, null)" type="button">
<icon name="remove" />
</button>
</form>
<form class="menububble__form" v-if="linkMenuIsActive" @submit.prevent="setLinkUrl(commands.link, linkUrl)">
<input class="menububble__input" type="text" v-model="linkUrl" placeholder="https://" ref="linkInput" @keydown.esc="hideLinkMenu"/>
<button class="menububble__button" @click="setLinkUrl(commands.link, null)" type="button">
<icon name="remove" />
</button>
</form>
<template v-else>
<button
class="menububble__button"
@click="showLinkMenu(markAttrs('link'))"
:class="{ 'is-active': isActive('link') }"
>
<span>Add Link</span>
<icon name="link" />
</button>
</template>
<template v-else>
<button
class="menububble__button"
@click="showLinkMenu(markAttrs('link'))"
:class="{ 'is-active': isActive('link') }"
>
<span>Add Link</span>
<icon name="link" />
</button>
</template>
</div>
</menu-bubble>
</div>
</menu-bubble>
<editor-content class="editor__content" :editor="editor" />
</div>
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, MenuBubble } from 'tiptap'
import {
Blockquote,
BulletList,
CodeBlock,
HardBreak,
Heading,
ListItem,
OrderedList,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
History,
Blockquote,
BulletList,
CodeBlock,
HardBreak,
Heading,
ListItem,
OrderedList,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
History,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
MenuBubble,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new History(),
],
content: `
<h2>
Links
</h2>
<p>
Try to add some links to the <a href="https://en.wikipedia.org/wiki/World_Wide_Web">world wide web</a>. By default every link will get a <code>rel="noopener noreferrer nofollow"</code> attribute.
</p>
`,
}),
linkUrl: null,
linkMenuIsActive: false,
}
},
methods: {
showLinkMenu(attrs) {
this.linkUrl = attrs.href
this.linkMenuIsActive = true
this.$nextTick(() => {
this.$refs.linkInput.focus()
})
},
hideLinkMenu() {
this.linkUrl = null
this.linkMenuIsActive = false
},
setLinkUrl(command, url) {
command({ href: url })
this.hideLinkMenu()
this.editor.focus()
},
},
components: {
EditorContent,
MenuBubble,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new History(),
],
content: `
<h2>
Links
</h2>
<p>
Try to add some links to the <a href="https://en.wikipedia.org/wiki/World_Wide_Web">world wide web</a>. By default every link will get a <code>rel="noopener noreferrer nofollow"</code> attribute.
</p>
`,
}),
linkUrl: null,
linkMenuIsActive: false,
}
},
methods: {
showLinkMenu(attrs) {
this.linkUrl = attrs.href
this.linkMenuIsActive = true
this.$nextTick(() => {
this.$refs.linkInput.focus()
})
},
hideLinkMenu() {
this.linkUrl = null
this.linkMenuIsActive = false
},
setLinkUrl(command, url) {
command({ href: url })
this.hideLinkMenu()
this.editor.focus()
},
},
}
</script>

View File

@@ -1,69 +1,69 @@
<template>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent } from 'tiptap'
import {
Blockquote,
CodeBlock,
HardBreak,
Heading,
OrderedList,
BulletList,
ListItem,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
History,
Blockquote,
CodeBlock,
HardBreak,
Heading,
OrderedList,
BulletList,
ListItem,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
History,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new History(),
],
content: `
<h2>
Markdown Shortcuts
</h2>
<p>
Start a new line and type <code>#</code> followed by a <code>space</code> and you will get an H1 headline.
</p>
<p>
This feature is called <strong>input rules</strong>. There are some of these shortcuts for the most basic nodes enabled by default. Try <code>#, ##, ###, …</code> for headlines, <code>></code> for blockquotes, <code>- or +</code> for bullet lists. And of course you can add your own input rules.
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new History(),
],
content: `
<h2>
Markdown Shortcuts
</h2>
<p>
Start a new line and type <code>#</code> followed by a <code>space</code> and you will get an H1 headline.
</p>
<p>
This feature is called <strong>input rules</strong>. There are some of these shortcuts for the most basic nodes enabled by default. Try <code>#, ##, ###, …</code> for headlines, <code>></code> for blockquotes, <code>- or +</code> for bullet lists. And of course you can add your own input rules.
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>

View File

@@ -1,106 +1,106 @@
<template>
<div class="editor">
<menu-bubble :editor="editor">
<div
slot-scope="{ commands, isActive, menu }"
class="menububble"
:class="{ 'is-active': menu.isActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<div class="editor">
<menu-bubble :editor="editor">
<div
slot-scope="{ commands, isActive, menu }"
class="menububble"
:class="{ 'is-active': menu.isActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<button
class="menububble__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menububble__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menububble__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menububble__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menububble__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menububble__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
</div>
</menu-bubble>
</div>
</menu-bubble>
<editor-content class="editor__content" :editor="editor" />
</div>
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, MenuBubble } from 'tiptap'
import {
Blockquote,
BulletList,
CodeBlock,
HardBreak,
Heading,
ListItem,
OrderedList,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
Strike,
Underline,
History,
Blockquote,
BulletList,
CodeBlock,
HardBreak,
Heading,
ListItem,
OrderedList,
TodoItem,
TodoList,
Bold,
Code,
Italic,
Link,
Strike,
Underline,
History,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
MenuBubble,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new Strike(),
new Underline(),
new History(),
],
content: `
<h2>
Menu Bubble
</h2>
<p>
Hey, try to select some text here. There will popup a menu for selecting some inline styles. <em>Remember:</em> you have full control about content and styling of this menu.
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
MenuBubble,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new Blockquote(),
new BulletList(),
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new ListItem(),
new OrderedList(),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
new Link(),
new Strike(),
new Underline(),
new History(),
],
content: `
<h2>
Menu Bubble
</h2>
<p>
Hey, try to select some text here. There will popup a menu for selecting some inline styles. <em>Remember:</em> you have full control about content and styling of this menu.
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>

View File

@@ -1,47 +1,47 @@
<template>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import { Editor, EditorContent } from 'tiptap'
import {
BulletList,
ListItem,
Placeholder,
BulletList,
ListItem,
Placeholder,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
extensions: [
new BulletList(),
new ListItem(),
new Placeholder({
emptyClass: 'is-empty',
}),
],
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
extensions: [
new BulletList(),
new ListItem(),
new Placeholder({
emptyClass: 'is-empty',
}),
],
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
.editor p.is-empty:first-child::before {
content: 'Start typing…';
float: left;
color: #aaa;
pointer-events: none;
height: 0;
font-style: italic;
content: 'Start typing…';
float: left;
color: #aaa;
pointer-events: none;
height: 0;
font-style: italic;
}
</style>

View File

@@ -1,49 +1,49 @@
<template>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import { Editor, EditorContent } from 'tiptap'
import {
HardBreak,
Heading,
Bold,
Code,
Italic,
Link,
HardBreak,
Heading,
Bold,
Code,
Italic,
Link,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
editable: false,
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Bold(),
new Code(),
new Italic(),
new Link(),
],
content: `
<h2>
Read-Only
</h2>
<p>
This text is <strong>read-only</strong>. You are not able to edit something. <a href="https://scrumpy.io/">Links to fancy websites</a> are still working.
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
editable: false,
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Bold(),
new Code(),
new Italic(),
new Link(),
],
content: `
<h2>
Read-Only
</h2>
<p>
This text is <strong>read-only</strong>. You are not able to edit something. <a href="https://scrumpy.io/">Links to fancy websites</a> are still working.
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>

View File

@@ -1,28 +1,28 @@
<template>
<div>
<div>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="editor">
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="suggestion-list" v-show="showSuggestions" ref="suggestions">
<template v-if="hasResults">
<div
v-for="(user, index) in filteredUsers"
:key="user.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedUserIndex === index }"
@click="selectUser(user)"
>
{{ user.name }}
</div>
</template>
<div v-else class="suggestion-list__item is-empty">
No users found
</div>
</div>
<div class="suggestion-list" v-show="showSuggestions" ref="suggestions">
<template v-if="hasResults">
<div
v-for="(user, index) in filteredUsers"
:key="user.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedUserIndex === index }"
@click="selectUser(user)"
>
{{ user.name }}
</div>
</template>
<div v-else class="suggestion-list__item is-empty">
No users found
</div>
</div>
</div>
</div>
</template>
<script>
@@ -30,204 +30,204 @@ import Fuse from 'fuse.js'
import tippy from 'tippy.js'
import { Editor, EditorContent } from 'tiptap'
import {
HardBreak,
Heading,
Mention,
Code,
Bold,
Italic,
HardBreak,
Heading,
Mention,
Code,
Bold,
Italic,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
},
components: {
EditorContent,
},
data() {
return {
editor: new Editor({
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Mention({
// a list of all suggested items
items: () => [
{ id: 1, name: 'Philipp Kühn' },
{ id: 2, name: 'Hans Pagel' },
{ id: 3, name: 'Kris Siepert' },
{ id: 4, name: 'Justin Schueler' },
],
// is called when a suggestion starts
onEnter: ({
items, query, range, command, virtualNode,
}) => {
this.query = query
this.filteredUsers = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMention = command
},
// is called when a suggestion has changed
onChange: ({
items, query, range, virtualNode,
}) => {
this.query = query
this.filteredUsers = items
this.suggestionRange = range
this.navigatedUserIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
// reset all saved values
this.query = null
this.filteredUsers = []
this.suggestionRange = null
this.navigatedUserIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
data() {
return {
editor: new Editor({
extensions: [
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new Mention({
// a list of all suggested items
items: () => [
{ id: 1, name: 'Philipp Kühn' },
{ id: 2, name: 'Hans Pagel' },
{ id: 3, name: 'Kris Siepert' },
{ id: 4, name: 'Justin Schueler' },
],
// is called when a suggestion starts
onEnter: ({
items, query, range, command, virtualNode,
}) => {
this.query = query
this.filteredUsers = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMention = command
},
// is called when a suggestion has changed
onChange: ({
items, query, range, virtualNode,
}) => {
this.query = query
this.filteredUsers = items
this.suggestionRange = range
this.navigatedUserIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
// reset all saved values
this.query = null
this.filteredUsers = []
this.suggestionRange = null
this.navigatedUserIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
if (!query) {
return items
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
if (!query) {
return items
}
const fuse = new Fuse(items, {
threshold: 0.2,
keys: ['name'],
})
const fuse = new Fuse(items, {
threshold: 0.2,
keys: ['name'],
})
return fuse.search(query)
},
}),
new Code(),
new Bold(),
new Italic(),
],
content: `
<h2>
Suggestions
</h2>
<p>
Sometimes it's useful to <strong>mention</strong> someone. That's a feature we're very used to. Under the hood this technique can also be used for other features likes <strong>hashtags</strong> and <strong>commands</strong> lets call it <em>suggestions</em>.
</p>
<p>
This is an example how to mention some users like <span data-mention-id="1">Philipp Kühn</span> or <span data-mention-id="2">Hans Pagel</span>. Try to type <code>@</code> and a popup (rendered with tippy.js) will appear. You can navigate with arrow keys through a list of suggestions.
</p>
`,
}),
query: null,
suggestionRange: null,
filteredUsers: [],
navigatedUserIndex: 0,
insertMention: () => {},
}
},
return fuse.search(query)
},
}),
new Code(),
new Bold(),
new Italic(),
],
content: `
<h2>
Suggestions
</h2>
<p>
Sometimes it's useful to <strong>mention</strong> someone. That's a feature we're very used to. Under the hood this technique can also be used for other features likes <strong>hashtags</strong> and <strong>commands</strong> lets call it <em>suggestions</em>.
</p>
<p>
This is an example how to mention some users like <span data-mention-id="1">Philipp Kühn</span> or <span data-mention-id="2">Hans Pagel</span>. Try to type <code>@</code> and a popup (rendered with tippy.js) will appear. You can navigate with arrow keys through a list of suggestions.
</p>
`,
}),
query: null,
suggestionRange: null,
filteredUsers: [],
navigatedUserIndex: 0,
insertMention: () => {},
}
},
computed: {
computed: {
hasResults() {
return this.filteredUsers.length
},
hasResults() {
return this.filteredUsers.length
},
showSuggestions() {
return this.query || this.hasResults
},
showSuggestions() {
return this.query || this.hasResults
},
},
},
methods: {
methods: {
// navigate to the previous item
// if it's the first item, navigate to the last one
upHandler() {
this.navigatedUserIndex = ((this.navigatedUserIndex + this.filteredUsers.length) - 1) % this.filteredUsers.length
},
// navigate to the previous item
// if it's the first item, navigate to the last one
upHandler() {
this.navigatedUserIndex = ((this.navigatedUserIndex + this.filteredUsers.length) - 1) % this.filteredUsers.length
},
// navigate to the next item
// if it's the last item, navigate to the first one
downHandler() {
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredUsers.length
},
// navigate to the next item
// if it's the last item, navigate to the first one
downHandler() {
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredUsers.length
},
enterHandler() {
const user = this.filteredUsers[this.navigatedUserIndex]
enterHandler() {
const user = this.filteredUsers[this.navigatedUserIndex]
if (user) {
this.selectUser(user)
}
},
if (user) {
this.selectUser(user)
}
},
// we have to replace our suggestion text with a mention
// so it's important to pass also the position of your suggestion text
selectUser(user) {
this.insertMention({
range: this.suggestionRange,
attrs: {
id: user.id,
label: user.name,
},
})
this.editor.focus()
},
// we have to replace our suggestion text with a mention
// so it's important to pass also the position of your suggestion text
selectUser(user) {
this.insertMention({
range: this.suggestionRange,
attrs: {
id: user.id,
label: user.name,
},
})
this.editor.focus()
},
// renders a popup with suggestions
// tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
renderPopup(node) {
if (this.popup) {
return
}
// renders a popup with suggestions
// tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
renderPopup(node) {
if (this.popup) {
return
}
this.popup = tippy(node, {
content: this.$refs.suggestions,
trigger: 'mouseenter',
interactive: true,
theme: 'dark',
placement: 'top-start',
performance: true,
inertia: true,
duration: [400, 200],
showOnInit: true,
arrow: true,
arrowType: 'round',
})
},
this.popup = tippy(node, {
content: this.$refs.suggestions,
trigger: 'mouseenter',
interactive: true,
theme: 'dark',
placement: 'top-start',
performance: true,
inertia: true,
duration: [400, 200],
showOnInit: true,
arrow: true,
arrowType: 'round',
})
},
destroyPopup() {
if (this.popup) {
this.popup.destroyAll()
this.popup = null
}
},
destroyPopup() {
if (this.popup) {
this.popup.destroyAll()
this.popup = null
}
},
},
},
}
</script>
@@ -242,74 +242,74 @@ export default {
font-weight: bold;
border-radius: 5px;
padding: 0.2rem 0.5rem;
white-space: nowrap;
white-space: nowrap;
}
.mention-suggestion {
color: rgba($color-black, 0.6);
color: rgba($color-black, 0.6);
}
.suggestion-list {
padding: 0.2rem;
border: 2px solid rgba($color-black, 0.1);
font-size: 0.8rem;
font-weight: bold;
padding: 0.2rem;
border: 2px solid rgba($color-black, 0.1);
font-size: 0.8rem;
font-weight: bold;
&__no-results {
padding: 0.2rem 0.5rem;
}
&__no-results {
padding: 0.2rem 0.5rem;
}
&__item {
border-radius: 5px;
padding: 0.2rem 0.5rem;
margin-bottom: 0.2rem;
cursor: pointer;
&__item {
border-radius: 5px;
padding: 0.2rem 0.5rem;
margin-bottom: 0.2rem;
cursor: pointer;
&:last-child {
margin-bottom: 0;
}
&:last-child {
margin-bottom: 0;
}
&.is-selected,
&:hover {
background-color: rgba($color-white, 0.2);
}
&.is-selected,
&:hover {
background-color: rgba($color-white, 0.2);
}
&.is-empty {
opacity: 0.5;
}
}
&.is-empty {
opacity: 0.5;
}
}
}
.tippy-tooltip.dark-theme {
background-color: $color-black;
padding: 0;
font-size: 1rem;
text-align: inherit;
color: $color-white;
border-radius: 5px;
background-color: $color-black;
padding: 0;
font-size: 1rem;
text-align: inherit;
color: $color-white;
border-radius: 5px;
.tippy-backdrop {
display: none;
}
.tippy-backdrop {
display: none;
}
.tippy-roundarrow {
fill: $color-black;
}
.tippy-roundarrow {
fill: $color-black;
}
.tippy-popper[x-placement^=top] & .tippy-arrow {
border-top-color: $color-black;
}
.tippy-popper[x-placement^=top] & .tippy-arrow {
border-top-color: $color-black;
}
.tippy-popper[x-placement^=bottom] & .tippy-arrow {
border-bottom-color: $color-black;
}
.tippy-popper[x-placement^=bottom] & .tippy-arrow {
border-bottom-color: $color-black;
}
.tippy-popper[x-placement^=left] & .tippy-arrow {
border-left-color: $color-black;
}
.tippy-popper[x-placement^=left] & .tippy-arrow {
border-left-color: $color-black;
}
.tippy-popper[x-placement^=right] & .tippy-arrow {
border-right-color: $color-black;
}
.tippy-popper[x-placement^=right] & .tippy-arrow {
border-right-color: $color-black;
}
}
</style>

View File

@@ -3,32 +3,32 @@ import { Node } from 'tiptap'
export default class Paragraph extends Node {
get name() {
return 'paragraph'
}
get name() {
return 'paragraph'
}
get schema() {
return {
attrs: {
textAlign: {
default: 'left',
},
},
content: 'inline*',
group: 'block',
draggable: false,
parseDOM: [{
tag: 'p',
getAttrs: node => ({
textAlign: node.style.textAlign,
}),
}],
toDOM: node => ['p', { style: `text-align: ${node.attrs.textAlign}` }, 0],
}
}
get schema() {
return {
attrs: {
textAlign: {
default: 'left',
},
},
content: 'inline*',
group: 'block',
draggable: false,
parseDOM: [{
tag: 'p',
getAttrs: node => ({
textAlign: node.style.textAlign,
}),
}],
toDOM: node => ['p', { style: `text-align: ${node.attrs.textAlign}` }, 0],
}
}
commands({ type }) {
return attrs => setBlockType(type, attrs)
}
commands({ type }) {
return attrs => setBlockType(type, attrs)
}
}

View File

@@ -1,93 +1,93 @@
<template>
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands, isActive }">
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands, isActive }">
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph', { textAlign: 'left' }) }"
@click="commands.paragraph({ textAlign: 'left' })"
>
<icon name="align-left" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph', { textAlign: 'left' }) }"
@click="commands.paragraph({ textAlign: 'left' })"
>
<icon name="align-left" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph', { textAlign: 'center' }) }"
@click="commands.paragraph({ textAlign: 'center' })"
>
<icon name="align-center" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph', { textAlign: 'center' }) }"
@click="commands.paragraph({ textAlign: 'center' })"
>
<icon name="align-center" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph', { textAlign: 'right' }) }"
@click="commands.paragraph({ textAlign: 'right' })"
>
<icon name="align-right" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('paragraph', { textAlign: 'right' }) }"
@click="commands.paragraph({ textAlign: 'right' })"
>
<icon name="align-right" />
</button>
</div>
</menu-bar>
</div>
</menu-bar>
<editor-content class="editor__content" :editor="editor" />
</div>
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, MenuBar } from 'tiptap'
import {
HardBreak,
Code,
HardBreak,
Code,
} from 'tiptap-extensions'
import ParagraphAlignment from './Paragraph.js'
export default {
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new HardBreak(),
new Code(),
new ParagraphAlignment(),
],
content: `
<p style="text-align: left">
Maybe you want to implement text alignment. If so, you're able to overwrite the default <code>ParagraphNode</code>. You can define some classes oder inline styles in your schema to achive that.
</p>
<p style="text-align: right">
Have fun! 🙌
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new HardBreak(),
new Code(),
new ParagraphAlignment(),
],
content: `
<p style="text-align: left">
Maybe you want to implement text alignment. If so, you're able to overwrite the default <code>ParagraphNode</code>. You can define some classes oder inline styles in your schema to achive that.
</p>
<p style="text-align: right">
Have fun! 🙌
</p>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>
<style lang="scss">
.text-align {
&--left {
text-align: left;
}
&--left {
text-align: left;
}
&--center {
text-align: center;
}
&--center {
text-align: center;
}
&--right {
text-align: right;
}
&--right {
text-align: right;
}
}
</style>

View File

@@ -1,108 +1,108 @@
<template>
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands, isActive }">
<div class="editor">
<menu-bar :editor="editor">
<div class="menubar" slot-scope="{ commands, isActive }">
<button
class="menubar__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('bold') }"
@click="commands.bold"
>
<icon name="bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('italic') }"
@click="commands.italic"
>
<icon name="italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('code') }"
@click="commands.code"
>
<icon name="code" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('todo_list') }"
@click="commands.todo_list"
>
<icon name="checklist" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive('todo_list') }"
@click="commands.todo_list"
>
<icon name="checklist" />
</button>
</div>
</menu-bar>
</div>
</menu-bar>
<editor-content class="editor__content" :editor="editor" />
</div>
<editor-content class="editor__content" :editor="editor" />
</div>
</template>
<script>
import Icon from 'Components/Icon'
import { Editor, EditorContent, MenuBar } from 'tiptap'
import {
CodeBlock,
HardBreak,
Heading,
TodoItem,
TodoList,
Bold,
Code,
Italic,
CodeBlock,
HardBreak,
Heading,
TodoItem,
TodoList,
Bold,
Code,
Italic,
} from 'tiptap-extensions'
export default {
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
],
content: `
<h2>
Todo List
</h2>
<p>
There is always something to do. Thankfully, there are checklists for that. Don't forget to call mom.
</p>
<ul data-type="todo_list">
<li data-type="todo_item" data-done="true">
Buy beer
</li>
<li data-type="todo_item" data-done="true">
Buy meat
</li>
<li data-type="todo_item" data-done="true">
Buy milk
</li>
<li data-type="todo_item" data-done="false">
Call mom
</li>
</ul>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
components: {
EditorContent,
MenuBar,
Icon,
},
data() {
return {
editor: new Editor({
extensions: [
new CodeBlock(),
new HardBreak(),
new Heading({ levels: [1, 2, 3] }),
new TodoItem(),
new TodoList(),
new Bold(),
new Code(),
new Italic(),
],
content: `
<h2>
Todo List
</h2>
<p>
There is always something to do. Thankfully, there are checklists for that. Don't forget to call mom.
</p>
<ul data-type="todo_list">
<li data-type="todo_item" data-done="true">
Buy beer
</li>
<li data-type="todo_item" data-done="true">
Buy meat
</li>
<li data-type="todo_item" data-done="true">
Buy milk
</li>
<li data-type="todo_item" data-done="false">
Call mom
</li>
</ul>
`,
}),
}
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>

View File

@@ -1,51 +1,51 @@
<template>
<div class="subnavigation">
<router-link class="subnavigation__link" to="/">
Basic
</router-link>
<router-link class="subnavigation__link" to="/menu-bubble">
Menu Bubble
</router-link>
<router-link class="subnavigation__link" to="/floating-menu">
Floating Menu
</router-link>
<router-link class="subnavigation__link" to="/links">
Links
</router-link>
<router-link class="subnavigation__link" to="/images">
Images
</router-link>
<router-link class="subnavigation__link" to="/text-align">
Text Align
</router-link>
<router-link class="subnavigation__link" to="/hiding-menu-bar">
Hiding Menu Bar
</router-link>
<router-link class="subnavigation__link" to="/todo-list">
Todo List
</router-link>
<router-link class="subnavigation__link" to="/suggestions">
Suggestions
</router-link>
<router-link class="subnavigation__link" to="/markdown-shortcuts">
Markdown Shortcuts
</router-link>
<router-link class="subnavigation__link" to="/code-highlighting">
Code Highlighting
</router-link>
<router-link class="subnavigation__link" to="/read-only">
Read-Only
</router-link>
<router-link class="subnavigation__link" to="/embeds">
Embeds
</router-link>
<router-link class="subnavigation__link" to="/placeholder">
Placeholder
</router-link>
<router-link class="subnavigation__link" to="/export">
Export HTML or JSON
</router-link>
</div>
<div class="subnavigation">
<router-link class="subnavigation__link" to="/">
Basic
</router-link>
<router-link class="subnavigation__link" to="/menu-bubble">
Menu Bubble
</router-link>
<router-link class="subnavigation__link" to="/floating-menu">
Floating Menu
</router-link>
<router-link class="subnavigation__link" to="/links">
Links
</router-link>
<router-link class="subnavigation__link" to="/images">
Images
</router-link>
<router-link class="subnavigation__link" to="/text-align">
Text Align
</router-link>
<router-link class="subnavigation__link" to="/hiding-menu-bar">
Hiding Menu Bar
</router-link>
<router-link class="subnavigation__link" to="/todo-list">
Todo List
</router-link>
<router-link class="subnavigation__link" to="/suggestions">
Suggestions
</router-link>
<router-link class="subnavigation__link" to="/markdown-shortcuts">
Markdown Shortcuts
</router-link>
<router-link class="subnavigation__link" to="/code-highlighting">
Code Highlighting
</router-link>
<router-link class="subnavigation__link" to="/read-only">
Read-Only
</router-link>
<router-link class="subnavigation__link" to="/embeds">
Embeds
</router-link>
<router-link class="subnavigation__link" to="/placeholder">
Placeholder
</router-link>
<router-link class="subnavigation__link" to="/export">
Export HTML or JSON
</router-link>
</div>
</template>
<style lang="scss" src="./style.scss" scoped></style>

View File

@@ -2,35 +2,35 @@
.subnavigation {
padding: 0.5rem;
background-color: rgba($color-black, 0.9);
color: $color-white;
text-align: center;
padding: 0.5rem;
background-color: rgba($color-black, 0.9);
color: $color-white;
text-align: center;
@media (min-width: 600px) {
position: sticky;
top: 0;
z-index: 1000;
}
@media (min-width: 600px) {
position: sticky;
top: 0;
z-index: 1000;
}
&__link {
display: inline-block;
color: rgba($color-white, 0.5);
text-decoration: none;
font-weight: bold;
font-size: 0.9rem;
padding: 0.1rem 0.5rem;
border-radius: 3px;
&__link {
display: inline-block;
color: rgba($color-white, 0.5);
text-decoration: none;
font-weight: bold;
font-size: 0.9rem;
padding: 0.1rem 0.5rem;
border-radius: 3px;
&:hover {
color: $color-white;
&:hover {
color: $color-white;
background-color: rgba($color-white, 0.1);
}
&.is-exact-active {
color: $color-white;
color: $color-white;
background-color: rgba($color-white, 0.2);
}
}
}
}

View File

@@ -1,39 +1,39 @@
@import "~variables";
* {
margin: 0;
padding: 0;
box-sizing: border-box;
text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-touch-callout: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
margin: 0;
padding: 0;
box-sizing: border-box;
text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-touch-callout: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
&:focus {
outline: none;
}
&:focus {
outline: none;
}
}
*::before,
*::after {
box-sizing: border-box;
box-sizing: border-box;
}
html {
font-family: -apple-system, BlinkMacSystemFont, San Francisco, Roboto, Segoe UI, Helvetica Neue, sans-serif;
font-size: 18px;
color: $color-black;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, San Francisco, Roboto, Segoe UI, Helvetica Neue, sans-serif;
font-size: 18px;
color: $color-black;
line-height: 1.5;
}
body {
margin: 0;
margin: 0;
}
a {
color: inherit;
color: inherit;
}
h1,
@@ -44,11 +44,11 @@ ul,
ol,
pre,
blockquote {
margin: 1rem 0;
margin: 1rem 0;
&:first-child {
margin-top: 0;
}
}
&:last-child {
margin-bottom: 0;
@@ -58,20 +58,20 @@ blockquote {
h1,
h2,
h3 {
line-height: 1.3;
line-height: 1.3;
}
.button {
font-weight: bold;
display: inline-flex;
background: transparent;
border: 0;
color: $color-black;
padding: 0.2rem 0.5rem;
margin-right: 0.2rem;
border-radius: 3px;
cursor: pointer;
background-color: rgba($color-black, 0.1);
font-weight: bold;
display: inline-flex;
background: transparent;
border: 0;
color: $color-black;
padding: 0.2rem 0.5rem;
margin-right: 0.2rem;
border-radius: 3px;
cursor: pointer;
background-color: rgba($color-black, 0.1);
}
@import "./editor";

View File

@@ -1,80 +1,80 @@
;(function(window, document) {
'use strict';
'use strict';
var isSvg = document.createElementNS && document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ).createSVGRect;
var localStorage = 'localStorage' in window && window['localStorage'] !== null ? window.localStorage : false;
var isSvg = document.createElementNS && document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' ).createSVGRect;
var localStorage = 'localStorage' in window && window['localStorage'] !== null ? window.localStorage : false;
function svgSpriteInjector(source, opts) {
var file;
opts = opts || {};
function svgSpriteInjector(source, opts) {
var file;
opts = opts || {};
if (source instanceof Node) {
file = source.getAttribute('data-svg-sprite');
opts.revision = source.getAttribute('data-svg-sprite-revision') || opts.revision;
} else if (typeof source === 'string') {
file = source;
}
if (source instanceof Node) {
file = source.getAttribute('data-svg-sprite');
opts.revision = source.getAttribute('data-svg-sprite-revision') || opts.revision;
} else if (typeof source === 'string') {
file = source;
}
if (isSvg) {
if (file) {
injector(file, opts);
} else {
console.error('svg-sprite-injector: undefined sprite filename!');
}
} else {
console.error('svg-sprite-injector require ie9 or greater!');
}
};
if (isSvg) {
if (file) {
injector(file, opts);
} else {
console.error('svg-sprite-injector: undefined sprite filename!');
}
} else {
console.error('svg-sprite-injector require ie9 or greater!');
}
};
function injector(filepath, opts) {
var name = 'injectedSVGSprite' + filepath,
revision = opts.revision,
request;
function injector(filepath, opts) {
var name = 'injectedSVGSprite' + filepath,
revision = opts.revision,
request;
// localStorage cache
if (revision !== undefined && localStorage && localStorage[name + 'Rev'] == revision) {
return injectOnLoad(localStorage[name]);
}
// localStorage cache
if (revision !== undefined && localStorage && localStorage[name + 'Rev'] == revision) {
return injectOnLoad(localStorage[name]);
}
// Async load
request = new XMLHttpRequest();
request.open('GET', filepath, true);
request.onreadystatechange = function (e) {
var data;
// Async load
request = new XMLHttpRequest();
request.open('GET', filepath, true);
request.onreadystatechange = function (e) {
var data;
if (request.readyState === 4 && request.status >= 200 && request.status < 400) {
injectOnLoad(data = request.responseText);
if (revision !== undefined && localStorage) {
localStorage[name] = data;
localStorage[name + 'Rev'] = revision;
}
}
};
request.send();
}
if (request.readyState === 4 && request.status >= 200 && request.status < 400) {
injectOnLoad(data = request.responseText);
if (revision !== undefined && localStorage) {
localStorage[name] = data;
localStorage[name + 'Rev'] = revision;
}
}
};
request.send();
}
function injectOnLoad(data) {
if (data) {
if (document.body) {
injectData(data);
} else {
document.addEventListener('DOMContentLoaded', injectData.bind(null, data));
}
}
}
function injectOnLoad(data) {
if (data) {
if (document.body) {
injectData(data);
} else {
document.addEventListener('DOMContentLoaded', injectData.bind(null, data));
}
}
}
function injectData(data) {
var body = document.body;
body.insertAdjacentHTML('afterbegin', data);
if (body.firstChild.tagName === 'svg') {
body.firstChild.style.display = 'none';
}
}
function injectData(data) {
var body = document.body;
body.insertAdjacentHTML('afterbegin', data);
if (body.firstChild.tagName === 'svg') {
body.firstChild.style.display = 'none';
}
}
if (typeof exports === 'object') {
module.exports = svgSpriteInjector;
} else {
window.svgSpriteInjector = svgSpriteInjector;
}
if (typeof exports === 'object') {
module.exports = svgSpriteInjector;
} else {
window.svgSpriteInjector = svgSpriteInjector;
}
} (window, document));

View File

@@ -1,16 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>tiptap</title>
<meta name="description" content="A renderless & extendable rich-text editor for Vue.js">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="mobile-web-app-capable" content="yes">
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<meta property="og:image" content="/assets/images/open-graph.png">
</head>
<body>
<div id="app"></div>
</body>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>tiptap</title>
<meta name="description" content="A renderless & extendable rich-text editor for Vue.js">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="mobile-web-app-capable" content="yes">
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<meta property="og:image" content="/assets/images/open-graph.png">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -1,10 +1,10 @@
export default function (text = '') {
return (state, dispatch) => {
const { $from } = state.selection
const { pos } = $from.pos
return (state, dispatch) => {
const { $from } = state.selection
const { pos } = $from.pos
dispatch(state.tr.insertText(text, pos))
dispatch(state.tr.insertText(text, pos))
return true
}
return true
}
}

View File

@@ -1,26 +1,26 @@
import { InputRule } from 'prosemirror-inputrules'
export default function (regexp, markType, getAttrs) {
return new InputRule(regexp, (state, match, start, end) => {
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs
return new InputRule(regexp, (state, match, start, end) => {
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs
const { tr } = state
let markEnd = end
if (match[1]) {
const startSpaces = match[0].search(/\S/)
const textStart = start + match[0].indexOf(match[1])
const textEnd = textStart + match[1].length
if (textEnd < end) {
tr.delete(textEnd, end)
}
if (textStart > start) {
tr.delete(start + startSpaces, textStart)
}
markEnd = start + startSpaces + match[1].length
}
if (match[1]) {
const startSpaces = match[0].search(/\S/)
const textStart = start + match[0].indexOf(match[1])
const textEnd = textStart + match[1].length
if (textEnd < end) {
tr.delete(textEnd, end)
}
if (textStart > start) {
tr.delete(start + startSpaces, textStart)
}
markEnd = start + startSpaces + match[1].length
}
tr.addMark(start, markEnd, markType.create(attrs))
tr.removeStoredMark(markType) // Do not continue with mark.
return tr
})
tr.addMark(start, markEnd, markType.create(attrs))
tr.removeStoredMark(markType) // Do not continue with mark.
return tr
})
}

View File

@@ -1,6 +1,6 @@
export default function (type) {
return (state, dispatch) => {
const { from, to } = state.selection
return dispatch(state.tr.removeMark(from, to, type))
}
const { from, to } = state.selection
return dispatch(state.tr.removeMark(from, to, type))
}
}

View File

@@ -1,16 +1,16 @@
export default function (range, type, attrs = {}) {
return (state, dispatch) => {
const { $from } = state.selection
const index = $from.index()
return (state, dispatch) => {
const { $from } = state.selection
const index = $from.index()
if (!$from.parent.canReplaceWith(index, index, type)) {
return false
}
if (!$from.parent.canReplaceWith(index, index, type)) {
return false
}
if (dispatch) {
dispatch(state.tr.replaceWith(range.from, range.to, type.create(attrs)))
}
if (dispatch) {
dispatch(state.tr.replaceWith(range.from, range.to, type.create(attrs)))
}
return true
}
return true
}
}

View File

@@ -1,16 +1,16 @@
export default function (type, attrs = {}) {
return (state, dispatch) => {
const { $from } = state.selection
const index = $from.index()
return (state, dispatch) => {
const { $from } = state.selection
const index = $from.index()
if (!$from.parent.canReplaceWith(index, index, type)) {
return false
}
if (!$from.parent.canReplaceWith(index, index, type)) {
return false
}
if (dispatch) {
dispatch(state.tr.replaceSelectionWith(type.create(attrs)))
}
if (dispatch) {
dispatch(state.tr.replaceSelectionWith(type.create(attrs)))
}
return true
}
return true
}
}

View File

@@ -16,13 +16,13 @@ index = $pos.index(d)
if (node.type.spec.isolating) return false
let rest = node.content.cutByIndex(index, node.childCount)
const after = (typesAfter && typesAfter[i]) || node
if (after != node) rest = rest.replaceChild(0, after.type.create(after.attrs))
if (after != node) rest = rest.replaceChild(0, after.type.create(after.attrs))
/* Change starts from here */
// if (!node.canReplace(index + 1, node.childCount) || !after.type.validContent(rest))
/* Change starts from here */
// if (!node.canReplace(index + 1, node.childCount) || !after.type.validContent(rest))
// return false
if (!node.canReplace(index + 1, node.childCount)) return false
/* Change ends here */
/* Change ends here */
}
const index = $pos.indexAfter(base)
const baseType = typesAfter && typesAfter[0]
@@ -43,7 +43,7 @@ export default function splitListItem(itemType) {
// list item should be split. Otherwise, bail out and let next
// command handle lifting.
if ($from.depth == 2 || $from.node(-3).type != itemType
|| $from.index(-2) != $from.node(-2).childCount - 1) return false
|| $from.index(-2) != $from.node(-2).childCount - 1) return false
if (dispatch) {
let wrap = Fragment.empty; const
@@ -52,23 +52,23 @@ keepItem = $from.index(-1) > 0
// from the outer list item to the parent node of the cursor
for (let d = $from.depth - (keepItem ? 1 : 2); d >= $from.depth - 3; d--) wrap = Fragment.from($from.node(d).copy(wrap))
// Add a second list item with an empty default start node
wrap = wrap.append(Fragment.from(itemType.createAndFill()))
wrap = wrap.append(Fragment.from(itemType.createAndFill()))
const tr = state.tr.replace($from.before(keepItem ? null : -1), $from.after(-3), new Slice(wrap, keepItem ? 3 : 2, 2))
tr.setSelection(state.selection.constructor.near(tr.doc.resolve($from.pos + (keepItem ? 3 : 2))))
dispatch(tr.scrollIntoView())
}
return true
}
const nextType = $to.pos == $from.end() ? grandParent.contentMatchAt($from.indexAfter(-1)).defaultType : null
const tr = state.tr.delete($from.pos, $to.pos)
const nextType = $to.pos == $from.end() ? grandParent.contentMatchAt($from.indexAfter(-1)).defaultType : null
const tr = state.tr.delete($from.pos, $to.pos)
/* Change starts from here */
// let types = nextType && [null, {type: nextType}]
/* Change starts from here */
// let types = nextType && [null, {type: nextType}]
let types = nextType && [{ type: itemType }, { type: nextType }]
if (!types) types = [{ type: itemType }, null]
/* Change ends here */
/* Change ends here */
if (!canSplit(tr.doc, $from.pos, 2, types)) return false
if (!canSplit(tr.doc, $from.pos, 2, types)) return false
if (dispatch) dispatch(tr.split($from.pos, 2, [{ type: state.schema.nodes.todo_item, attrs: { done: false } }]).scrollIntoView())
return true
}

View File

@@ -2,13 +2,13 @@ import { setBlockType } from 'prosemirror-commands'
import { nodeIsActive } from 'tiptap-utils'
export default function (type, toggletype, attrs = {}) {
return (state, dispatch, view) => {
const isActive = nodeIsActive(state, type, attrs)
return (state, dispatch, view) => {
const isActive = nodeIsActive(state, type, attrs)
if (isActive) {
return setBlockType(toggletype)(state, dispatch, view)
}
if (isActive) {
return setBlockType(toggletype)(state, dispatch, view)
}
return setBlockType(type, attrs)(state, dispatch, view)
}
return setBlockType(type, attrs)(state, dispatch, view)
}
}

View File

@@ -2,13 +2,13 @@ import { wrapIn, lift } from 'prosemirror-commands'
import { nodeIsActive } from 'tiptap-utils'
export default function (type) {
return (state, dispatch, view) => {
const isActive = nodeIsActive(state, type)
return (state, dispatch, view) => {
const isActive = nodeIsActive(state, type)
if (isActive) {
return lift(state, dispatch)
}
if (isActive) {
return lift(state, dispatch)
}
return wrapIn(type)(state, dispatch, view)
}
return wrapIn(type)(state, dispatch, view)
}
}

View File

@@ -1,6 +1,6 @@
export default function (type, attrs) {
return (state, dispatch) => {
const { from, to } = state.selection
return dispatch(state.tr.addMark(from, to, type.create(attrs)))
}
const { from, to } = state.selection
return dispatch(state.tr.addMark(from, to, type.create(attrs)))
}
}

View File

@@ -1,41 +1,41 @@
import {
chainCommands,
deleteSelection,
joinBackward,
selectNodeBackward,
joinForward,
selectNodeForward,
joinUp,
joinDown,
lift,
newlineInCode,
exitCode,
createParagraphNear,
liftEmptyBlock,
splitBlock,
splitBlockKeepMarks,
selectParentNode,
selectAll,
wrapIn,
setBlockType,
toggleMark,
autoJoin,
baseKeymap,
pcBaseKeymap,
macBaseKeymap,
chainCommands,
deleteSelection,
joinBackward,
selectNodeBackward,
joinForward,
selectNodeForward,
joinUp,
joinDown,
lift,
newlineInCode,
exitCode,
createParagraphNear,
liftEmptyBlock,
splitBlock,
splitBlockKeepMarks,
selectParentNode,
selectAll,
wrapIn,
setBlockType,
toggleMark,
autoJoin,
baseKeymap,
pcBaseKeymap,
macBaseKeymap,
} from 'prosemirror-commands'
import {
addListNodes,
wrapInList,
splitListItem,
liftListItem,
sinkListItem,
addListNodes,
wrapInList,
splitListItem,
liftListItem,
sinkListItem,
} from 'prosemirror-schema-list'
import {
wrappingInputRule,
textblockTypeInputRule,
wrappingInputRule,
textblockTypeInputRule,
} from 'prosemirror-inputrules'
import insertText from './commands/insertText'
@@ -50,52 +50,52 @@ import toggleWrap from './commands/toggleWrap'
import updateMark from './commands/updateMark'
export {
// prosemirror-commands
chainCommands,
deleteSelection,
joinBackward,
selectNodeBackward,
joinForward,
selectNodeForward,
joinUp,
joinDown,
lift,
newlineInCode,
exitCode,
createParagraphNear,
liftEmptyBlock,
splitBlock,
splitBlockKeepMarks,
selectParentNode,
selectAll,
wrapIn,
setBlockType,
toggleMark,
autoJoin,
baseKeymap,
pcBaseKeymap,
macBaseKeymap,
// prosemirror-commands
chainCommands,
deleteSelection,
joinBackward,
selectNodeBackward,
joinForward,
selectNodeForward,
joinUp,
joinDown,
lift,
newlineInCode,
exitCode,
createParagraphNear,
liftEmptyBlock,
splitBlock,
splitBlockKeepMarks,
selectParentNode,
selectAll,
wrapIn,
setBlockType,
toggleMark,
autoJoin,
baseKeymap,
pcBaseKeymap,
macBaseKeymap,
// prosemirror-schema-list
addListNodes,
wrapInList,
splitListItem,
liftListItem,
sinkListItem,
// prosemirror-schema-list
addListNodes,
wrapInList,
splitListItem,
liftListItem,
sinkListItem,
// prosemirror-inputrules
wrappingInputRule,
textblockTypeInputRule,
// prosemirror-inputrules
wrappingInputRule,
textblockTypeInputRule,
// custom
insertText,
markInputRule,
removeMark,
replaceText,
setInlineBlockType,
splitToDefaultListItem,
toggleBlockType,
toggleList,
toggleWrap,
updateMark,
// custom
insertText,
markInputRule,
removeMark,
replaceText,
setInlineBlockType,
splitToDefaultListItem,
toggleBlockType,
toggleList,
toggleWrap,
updateMark,
}

View File

@@ -3,35 +3,35 @@ import { history, undo, redo } from 'prosemirror-history'
export default class History extends Extension {
get name() {
return 'history'
}
get name() {
return 'history'
}
keys() {
const isMac = typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : false
const keymap = {
'Mod-z': undo,
'Shift-Mod-z': redo,
}
keys() {
const isMac = typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : false
const keymap = {
'Mod-z': undo,
'Shift-Mod-z': redo,
}
if (!isMac) {
keymap['Mod-y'] = redo
}
if (!isMac) {
keymap['Mod-y'] = redo
}
return keymap
}
return keymap
}
get plugins() {
return [
history(),
]
}
get plugins() {
return [
history(),
]
}
commands() {
return {
undo: () => undo,
redo: () => redo,
}
}
commands() {
return {
undo: () => undo,
redo: () => redo,
}
}
}

View File

@@ -3,40 +3,40 @@ import { Decoration, DecorationSet } from 'prosemirror-view'
export default class Placeholder extends Extension {
get name() {
return 'placeholder'
}
get name() {
return 'placeholder'
}
get defaultOptions() {
return {
emptyNodeClass: 'is-empty',
}
}
get defaultOptions() {
return {
emptyNodeClass: 'is-empty',
}
}
get plugins() {
return [
new Plugin({
props: {
decorations: ({ doc }) => {
const decorations = []
const completelyEmpty = doc.textContent === '' && doc.childCount <= 1 && doc.content.size <= 2
get plugins() {
return [
new Plugin({
props: {
decorations: ({ doc }) => {
const decorations = []
const completelyEmpty = doc.textContent === '' && doc.childCount <= 1 && doc.content.size <= 2
doc.descendants((node, pos) => {
if (!completelyEmpty) {
return
}
doc.descendants((node, pos) => {
if (!completelyEmpty) {
return
}
const decoration = Decoration.node(pos, pos + node.nodeSize, {
class: this.options.emptyNodeClass,
})
decorations.push(decoration)
})
const decoration = Decoration.node(pos, pos + node.nodeSize, {
class: this.options.emptyNodeClass,
})
decorations.push(decoration)
})
return DecorationSet.create(doc, decorations)
},
},
}),
]
}
return DecorationSet.create(doc, decorations)
},
},
}),
]
}
}

View File

@@ -3,43 +3,43 @@ import { toggleMark, markInputRule } from 'tiptap-commands'
export default class Bold extends Mark {
get name() {
return 'bold'
}
get name() {
return 'bold'
}
get schema() {
return {
parseDOM: [
{
tag: 'strong',
},
{
tag: 'b',
getAttrs: node => node.style.fontWeight !== 'normal' && null,
},
{
style: 'font-weight',
getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null,
},
],
toDOM: () => ['strong', 0],
}
}
get schema() {
return {
parseDOM: [
{
tag: 'strong',
},
{
tag: 'b',
getAttrs: node => node.style.fontWeight !== 'normal' && null,
},
{
style: 'font-weight',
getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null,
},
],
toDOM: () => ['strong', 0],
}
}
keys({ type }) {
return {
'Mod-b': toggleMark(type),
}
}
keys({ type }) {
return {
'Mod-b': toggleMark(type),
}
}
commands({ type }) {
return () => toggleMark(type)
}
commands({ type }) {
return () => toggleMark(type)
}
inputRules({ type }) {
return [
markInputRule(/(?:\*\*|__)([^*_]+)(?:\*\*|__)$/, type),
]
}
inputRules({ type }) {
return [
markInputRule(/(?:\*\*|__)([^*_]+)(?:\*\*|__)$/, type),
]
}
}

View File

@@ -3,33 +3,33 @@ import { toggleMark, markInputRule } from 'tiptap-commands'
export default class Code extends Mark {
get name() {
return 'code'
}
get name() {
return 'code'
}
get schema() {
return {
parseDOM: [
{ tag: 'code' },
],
toDOM: () => ['code', 0],
}
}
get schema() {
return {
parseDOM: [
{ tag: 'code' },
],
toDOM: () => ['code', 0],
}
}
keys({ type }) {
return {
'Mod-`': toggleMark(type),
}
}
keys({ type }) {
return {
'Mod-`': toggleMark(type),
}
}
commands({ type }) {
return () => toggleMark(type)
}
commands({ type }) {
return () => toggleMark(type)
}
inputRules({ type }) {
return [
markInputRule(/(?:`)([^`]+)(?:`)$/, type),
]
}
inputRules({ type }) {
return [
markInputRule(/(?:`)([^`]+)(?:`)$/, type),
]
}
}

View File

@@ -3,35 +3,35 @@ import { toggleMark, markInputRule } from 'tiptap-commands'
export default class Italic extends Mark {
get name() {
return 'italic'
}
get name() {
return 'italic'
}
get schema() {
return {
parseDOM: [
{ tag: 'i' },
{ tag: 'em' },
{ style: 'font-style=italic' },
],
toDOM: () => ['em', 0],
}
}
get schema() {
return {
parseDOM: [
{ tag: 'i' },
{ tag: 'em' },
{ style: 'font-style=italic' },
],
toDOM: () => ['em', 0],
}
}
keys({ type }) {
return {
'Mod-i': toggleMark(type),
}
}
keys({ type }) {
return {
'Mod-i': toggleMark(type),
}
}
commands({ type }) {
return () => toggleMark(type)
}
commands({ type }) {
return () => toggleMark(type)
}
inputRules({ type }) {
return [
markInputRule(/(?:^|[^*_])(?:\*|_)([^*_]+)(?:\*|_)$/, type),
]
}
inputRules({ type }) {
return [
markInputRule(/(?:^|[^*_])(?:\*|_)([^*_]+)(?:\*|_)$/, type),
]
}
}

View File

@@ -3,41 +3,41 @@ import { updateMark, removeMark } from 'tiptap-commands'
export default class Link extends Mark {
get name() {
return 'link'
}
get name() {
return 'link'
}
get schema() {
return {
attrs: {
href: {
default: null,
},
},
inclusive: false,
parseDOM: [
{
tag: 'a[href]',
getAttrs: dom => ({
href: dom.getAttribute('href'),
}),
},
],
toDOM: node => ['a', {
...node.attrs,
rel: 'noopener noreferrer nofollow',
}, 0],
}
}
get schema() {
return {
attrs: {
href: {
default: null,
},
},
inclusive: false,
parseDOM: [
{
tag: 'a[href]',
getAttrs: dom => ({
href: dom.getAttribute('href'),
}),
},
],
toDOM: node => ['a', {
...node.attrs,
rel: 'noopener noreferrer nofollow',
}, 0],
}
}
commands({ type }) {
return attrs => {
if (attrs.href) {
return updateMark(type, attrs)
}
commands({ type }) {
return attrs => {
if (attrs.href) {
return updateMark(type, attrs)
}
return removeMark(type)
}
}
return removeMark(type)
}
}
}

View File

@@ -3,45 +3,45 @@ import { toggleMark, markInputRule } from 'tiptap-commands'
export default class Strike extends Mark {
get name() {
return 'strike'
}
get name() {
return 'strike'
}
get schema() {
return {
parseDOM: [
{
tag: 's',
},
{
tag: 'del',
},
{
tag: 'strike',
},
{
style: 'text-decoration',
getAttrs: value => value === 'line-through',
},
],
toDOM: () => ['s', 0],
}
}
get schema() {
return {
parseDOM: [
{
tag: 's',
},
{
tag: 'del',
},
{
tag: 'strike',
},
{
style: 'text-decoration',
getAttrs: value => value === 'line-through',
},
],
toDOM: () => ['s', 0],
}
}
keys({ type }) {
return {
'Mod-d': toggleMark(type),
}
}
keys({ type }) {
return {
'Mod-d': toggleMark(type),
}
}
commands({ type }) {
return () => toggleMark(type)
}
commands({ type }) {
return () => toggleMark(type)
}
inputRules({ type }) {
return [
markInputRule(/~([^~]+)~$/, type),
]
}
inputRules({ type }) {
return [
markInputRule(/~([^~]+)~$/, type),
]
}
}

View File

@@ -3,33 +3,33 @@ import { toggleMark } from 'tiptap-commands'
export default class Underline extends Mark {
get name() {
return 'underline'
}
get name() {
return 'underline'
}
get schema() {
return {
parseDOM: [
{
tag: 'u',
},
{
style: 'text-decoration',
getAttrs: value => value === 'underline',
},
],
toDOM: () => ['u', 0],
}
}
get schema() {
return {
parseDOM: [
{
tag: 'u',
},
{
style: 'text-decoration',
getAttrs: value => value === 'underline',
},
],
toDOM: () => ['u', 0],
}
}
keys({ type }) {
return {
'Mod-u': toggleMark(type),
}
}
keys({ type }) {
return {
'Mod-u': toggleMark(type),
}
}
commands({ type }) {
return () => toggleMark(type)
}
commands({ type }) {
return () => toggleMark(type)
}
}

View File

@@ -3,37 +3,37 @@ import { wrappingInputRule, toggleWrap } from 'tiptap-commands'
export default class Blockquote extends Node {
get name() {
return 'blockquote'
}
get name() {
return 'blockquote'
}
get schema() {
return {
content: 'block*',
group: 'block',
defining: true,
draggable: false,
parseDOM: [
{ tag: 'blockquote' },
],
toDOM: () => ['blockquote', 0],
}
}
get schema() {
return {
content: 'block*',
group: 'block',
defining: true,
draggable: false,
parseDOM: [
{ tag: 'blockquote' },
],
toDOM: () => ['blockquote', 0],
}
}
commands({ type, schema }) {
return () => toggleWrap(type, schema.nodes.paragraph)
}
commands({ type, schema }) {
return () => toggleWrap(type, schema.nodes.paragraph)
}
keys({ type }) {
return {
'Ctrl->': toggleWrap(type),
}
}
keys({ type }) {
return {
'Ctrl->': toggleWrap(type),
}
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*>\s$/, type),
]
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*>\s$/, type),
]
}
}

View File

@@ -3,35 +3,35 @@ import { wrappingInputRule, toggleList } from 'tiptap-commands'
export default class Bullet extends Node {
get name() {
return 'bullet_list'
}
get name() {
return 'bullet_list'
}
get schema() {
return {
content: 'list_item+',
group: 'block',
parseDOM: [
{ tag: 'ul' },
],
toDOM: () => ['ul', 0],
}
}
get schema() {
return {
content: 'list_item+',
group: 'block',
parseDOM: [
{ tag: 'ul' },
],
toDOM: () => ['ul', 0],
}
}
commands({ type, schema }) {
return () => toggleList(type, schema.nodes.list_item)
}
commands({ type, schema }) {
return () => toggleList(type, schema.nodes.list_item)
}
keys({ type, schema }) {
return {
'Shift-Ctrl-8': toggleList(type, schema.nodes.list_item),
}
}
keys({ type, schema }) {
return {
'Shift-Ctrl-8': toggleList(type, schema.nodes.list_item),
}
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*([-+*])\s$/, type),
]
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*([-+*])\s$/, type),
]
}
}

View File

@@ -3,39 +3,39 @@ import { toggleBlockType, setBlockType, textblockTypeInputRule } from 'tiptap-co
export default class CodeBlock extends Node {
get name() {
return 'code_block'
}
get name() {
return 'code_block'
}
get schema() {
return {
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
draggable: false,
parseDOM: [
{ tag: 'pre', preserveWhitespace: 'full' },
],
toDOM: () => ['pre', ['code', 0]],
}
}
get schema() {
return {
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
draggable: false,
parseDOM: [
{ tag: 'pre', preserveWhitespace: 'full' },
],
toDOM: () => ['pre', ['code', 0]],
}
}
commands({ type, schema }) {
return () => toggleBlockType(type, schema.nodes.paragraph)
}
commands({ type, schema }) {
return () => toggleBlockType(type, schema.nodes.paragraph)
}
keys({ type }) {
return {
'Shift-Ctrl-\\': setBlockType(type),
}
}
keys({ type }) {
return {
'Shift-Ctrl-\\': setBlockType(type),
}
}
inputRules({ type }) {
return [
textblockTypeInputRule(/^```$/, type),
]
}
inputRules({ type }) {
return [
textblockTypeInputRule(/^```$/, type),
]
}
}

View File

@@ -5,139 +5,139 @@ import { findBlockNodes } from 'prosemirror-utils'
import low from 'lowlight/lib/core'
function getDecorations(doc) {
const decorations = []
const decorations = []
const blocks = findBlockNodes(doc)
.filter(item => item.node.type.name === 'code_block')
const blocks = findBlockNodes(doc)
.filter(item => item.node.type.name === 'code_block')
const flatten = list => list.reduce(
(a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [],
)
const flatten = list => list.reduce(
(a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [],
)
function parseNodes(nodes, className = []) {
return nodes.map(node => {
function parseNodes(nodes, className = []) {
return nodes.map(node => {
const classes = [
...className,
...node.properties ? node.properties.className : [],
]
const classes = [
...className,
...node.properties ? node.properties.className : [],
]
if (node.children) {
return parseNodes(node.children, classes)
}
if (node.children) {
return parseNodes(node.children, classes)
}
return {
text: node.value,
classes,
}
})
}
return {
text: node.value,
classes,
}
})
}
blocks.forEach(block => {
let startPos = block.pos + 1
const nodes = low.highlightAuto(block.node.textContent).value
blocks.forEach(block => {
let startPos = block.pos + 1
const nodes = low.highlightAuto(block.node.textContent).value
flatten(parseNodes(nodes))
.map(node => {
const from = startPos
const to = from + node.text.length
flatten(parseNodes(nodes))
.map(node => {
const from = startPos
const to = from + node.text.length
startPos = to
startPos = to
return {
...node,
from,
to,
}
})
.forEach(node => {
const decoration = Decoration.inline(node.from, node.to, {
class: node.classes.join(' '),
})
decorations.push(decoration)
})
})
return {
...node,
from,
to,
}
})
.forEach(node => {
const decoration = Decoration.inline(node.from, node.to, {
class: node.classes.join(' '),
})
decorations.push(decoration)
})
})
return DecorationSet.create(doc, decorations)
return DecorationSet.create(doc, decorations)
}
export default class CodeBlockHighlight extends Node {
constructor(options = {}) {
super(options)
try {
Object.entries(this.options.languages).forEach(([name, mapping]) => {
low.registerLanguage(name, mapping)
})
} catch (err) {
throw new Error('Invalid syntax highlight definitions: define at least one highlight.js language mapping')
}
}
constructor(options = {}) {
super(options)
try {
Object.entries(this.options.languages).forEach(([name, mapping]) => {
low.registerLanguage(name, mapping)
})
} catch (err) {
throw new Error('Invalid syntax highlight definitions: define at least one highlight.js language mapping')
}
}
get defaultOptions() {
return {
languages: {},
}
}
get defaultOptions() {
return {
languages: {},
}
}
get name() {
return 'code_block'
}
get name() {
return 'code_block'
}
get schema() {
return {
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
draggable: false,
parseDOM: [
{ tag: 'pre', preserveWhitespace: 'full' },
],
toDOM: () => ['pre', ['code', 0]],
}
}
get schema() {
return {
content: 'text*',
marks: '',
group: 'block',
code: true,
defining: true,
draggable: false,
parseDOM: [
{ tag: 'pre', preserveWhitespace: 'full' },
],
toDOM: () => ['pre', ['code', 0]],
}
}
commands({ type, schema }) {
return () => toggleBlockType(type, schema.nodes.paragraph)
}
commands({ type, schema }) {
return () => toggleBlockType(type, schema.nodes.paragraph)
}
keys({ type }) {
return {
'Shift-Ctrl-\\': setBlockType(type),
}
}
keys({ type }) {
return {
'Shift-Ctrl-\\': setBlockType(type),
}
}
inputRules({ type }) {
return [
textblockTypeInputRule(/^```$/, type),
]
}
inputRules({ type }) {
return [
textblockTypeInputRule(/^```$/, type),
]
}
get plugins() {
return [
new Plugin({
state: {
init(_, { doc }) {
return getDecorations(doc)
},
apply(tr, set) {
// TODO: find way to cache decorations
// see: https://discuss.prosemirror.net/t/how-to-update-multiple-inline-decorations-on-node-change/1493
if (tr.docChanged) {
return getDecorations(tr.doc)
}
return set.map(tr.mapping, tr.doc)
},
},
props: {
decorations(state) {
return this.getState(state)
},
},
}),
]
}
get plugins() {
return [
new Plugin({
state: {
init(_, { doc }) {
return getDecorations(doc)
},
apply(tr, set) {
// TODO: find way to cache decorations
// see: https://discuss.prosemirror.net/t/how-to-update-multiple-inline-decorations-on-node-change/1493
if (tr.docChanged) {
return getDecorations(tr.doc)
}
return set.map(tr.mapping, tr.doc)
},
},
props: {
decorations(state) {
return this.getState(state)
},
},
}),
]
}
}

View File

@@ -3,31 +3,31 @@ import { chainCommands, exitCode } from 'tiptap-commands'
export default class HardBreak extends Node {
get name() {
return 'hard_break'
}
get name() {
return 'hard_break'
}
get schema() {
return {
inline: true,
group: 'inline',
selectable: false,
parseDOM: [
{ tag: 'br' },
],
toDOM: () => ['br'],
}
}
get schema() {
return {
inline: true,
group: 'inline',
selectable: false,
parseDOM: [
{ tag: 'br' },
],
toDOM: () => ['br'],
}
}
keys({ type }) {
const command = chainCommands(exitCode, (state, dispatch) => {
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView())
return true
})
return {
'Mod-Enter': command,
'Shift-Enter': command,
}
}
keys({ type }) {
const command = chainCommands(exitCode, (state, dispatch) => {
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView())
return true
})
return {
'Mod-Enter': command,
'Shift-Enter': command,
}
}
}

View File

@@ -3,55 +3,55 @@ import { setBlockType, textblockTypeInputRule, toggleBlockType } from 'tiptap-co
export default class Heading extends Node {
get name() {
return 'heading'
}
get name() {
return 'heading'
}
get defaultOptions() {
return {
levels: [1, 2, 3, 4, 5, 6],
}
}
get defaultOptions() {
return {
levels: [1, 2, 3, 4, 5, 6],
}
}
get schema() {
return {
attrs: {
level: {
default: 1,
},
},
content: 'inline*',
group: 'block',
defining: true,
draggable: false,
parseDOM: this.options.levels
.map(level => ({
tag: `h${level}`,
attrs: { level },
})),
toDOM: node => [`h${node.attrs.level}`, 0],
}
}
get schema() {
return {
attrs: {
level: {
default: 1,
},
},
content: 'inline*',
group: 'block',
defining: true,
draggable: false,
parseDOM: this.options.levels
.map(level => ({
tag: `h${level}`,
attrs: { level },
})),
toDOM: node => [`h${node.attrs.level}`, 0],
}
}
commands({ type, schema }) {
return attrs => toggleBlockType(type, schema.nodes.paragraph, attrs)
}
commands({ type, schema }) {
return attrs => toggleBlockType(type, schema.nodes.paragraph, attrs)
}
keys({ type }) {
return this.options.levels.reduce((items, level) => ({
...items,
...{
[`Shift-Ctrl-${level}`]: setBlockType(type, { level }),
},
}), {})
}
keys({ type }) {
return this.options.levels.reduce((items, level) => ({
...items,
...{
[`Shift-Ctrl-${level}`]: setBlockType(type, { level }),
},
}), {})
}
inputRules({ type }) {
return this.options.levels.map(level => textblockTypeInputRule(
new RegExp(`^(#{1,${level}})\\s$`),
type,
match => ({ level }),
))
}
inputRules({ type }) {
return this.options.levels.map(level => textblockTypeInputRule(
new RegExp(`^(#{1,${level}})\\s$`),
type,
match => ({ level }),
))
}
}

View File

@@ -2,92 +2,92 @@ import { Node, Plugin } from 'tiptap'
export default class Image extends Node {
get name() {
return 'image'
}
get name() {
return 'image'
}
get schema() {
return {
inline: true,
attrs: {
src: {},
alt: {
default: null,
},
title: {
default: null,
},
},
group: 'inline',
draggable: true,
parseDOM: [
{
tag: 'img[src]',
getAttrs: dom => ({
src: dom.getAttribute('src'),
title: dom.getAttribute('title'),
alt: dom.getAttribute('alt'),
}),
},
],
toDOM: node => ['img', node.attrs],
}
}
get schema() {
return {
inline: true,
attrs: {
src: {},
alt: {
default: null,
},
title: {
default: null,
},
},
group: 'inline',
draggable: true,
parseDOM: [
{
tag: 'img[src]',
getAttrs: dom => ({
src: dom.getAttribute('src'),
title: dom.getAttribute('title'),
alt: dom.getAttribute('alt'),
}),
},
],
toDOM: node => ['img', node.attrs],
}
}
commands({ type }) {
return attrs => (state, dispatch) => {
const { selection } = state
const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
const node = type.create(attrs)
const transaction = state.tr.insert(position, node)
dispatch(transaction)
}
}
commands({ type }) {
return attrs => (state, dispatch) => {
const { selection } = state
const position = selection.$cursor ? selection.$cursor.pos : selection.$to.pos
const node = type.create(attrs)
const transaction = state.tr.insert(position, node)
dispatch(transaction)
}
}
get plugins() {
return [
new Plugin({
props: {
handleDOMEvents: {
drop(view, event) {
const hasFiles = event.dataTransfer
&& event.dataTransfer.files
&& event.dataTransfer.files.length
get plugins() {
return [
new Plugin({
props: {
handleDOMEvents: {
drop(view, event) {
const hasFiles = event.dataTransfer
&& event.dataTransfer.files
&& event.dataTransfer.files.length
if (!hasFiles) {
return
}
if (!hasFiles) {
return
}
const images = Array
.from(event.dataTransfer.files)
.filter(file => (/image/i).test(file.type))
const images = Array
.from(event.dataTransfer.files)
.filter(file => (/image/i).test(file.type))
if (images.length === 0) {
return
}
if (images.length === 0) {
return
}
event.preventDefault()
event.preventDefault()
const { schema } = view.state
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })
const { schema } = view.state
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY })
images.forEach(image => {
const reader = new FileReader()
images.forEach(image => {
const reader = new FileReader()
reader.onload = readerEvent => {
const node = schema.nodes.image.create({
src: readerEvent.target.result,
})
const transaction = view.state.tr.insert(coordinates.pos, node)
view.dispatch(transaction)
}
reader.readAsDataURL(image)
})
},
},
},
}),
]
}
reader.onload = readerEvent => {
const node = schema.nodes.image.create({
src: readerEvent.target.result,
})
const transaction = view.state.tr.insert(coordinates.pos, node)
view.dispatch(transaction)
}
reader.readAsDataURL(image)
})
},
},
},
}),
]
}
}

View File

@@ -3,28 +3,28 @@ import { splitListItem, liftListItem, sinkListItem } from 'tiptap-commands'
export default class ListItem extends Node {
get name() {
return 'list_item'
}
get name() {
return 'list_item'
}
get schema() {
return {
content: 'paragraph block*',
defining: true,
draggable: false,
parseDOM: [
{ tag: 'li' },
],
toDOM: () => ['li', 0],
}
}
get schema() {
return {
content: 'paragraph block*',
defining: true,
draggable: false,
parseDOM: [
{ tag: 'li' },
],
toDOM: () => ['li', 0],
}
}
keys({ type }) {
return {
Enter: splitListItem(type),
Tab: sinkListItem(type),
'Shift-Tab': liftListItem(type),
}
}
keys({ type }) {
return {
Enter: splitListItem(type),
Tab: sinkListItem(type),
'Shift-Tab': liftListItem(type),
}
}
}

View File

@@ -4,68 +4,68 @@ import SuggestionsPlugin from '../plugins/Suggestions'
export default class Mention extends Node {
get name() {
return 'mention'
}
get name() {
return 'mention'
}
get defaultOptions() {
return {
matcher: {
char: '@',
allowSpaces: false,
startOfLine: false,
},
mentionClass: 'mention',
suggestionClass: 'mention-suggestion',
}
}
get defaultOptions() {
return {
matcher: {
char: '@',
allowSpaces: false,
startOfLine: false,
},
mentionClass: 'mention',
suggestionClass: 'mention-suggestion',
}
}
get schema() {
return {
attrs: {
id: {},
label: {},
},
group: 'inline',
inline: true,
selectable: false,
atom: true,
toDOM: node => [
'span',
{
class: this.options.mentionClass,
'data-mention-id': node.attrs.id,
},
`${this.options.matcher.char}${node.attrs.label}`,
],
parseDOM: [
{
tag: 'span[data-mention-id]',
getAttrs: dom => {
const id = dom.getAttribute('data-mention-id')
const label = dom.innerText.split(this.options.matcher.char).join('')
return { id, label }
},
},
],
}
}
get schema() {
return {
attrs: {
id: {},
label: {},
},
group: 'inline',
inline: true,
selectable: false,
atom: true,
toDOM: node => [
'span',
{
class: this.options.mentionClass,
'data-mention-id': node.attrs.id,
},
`${this.options.matcher.char}${node.attrs.label}`,
],
parseDOM: [
{
tag: 'span[data-mention-id]',
getAttrs: dom => {
const id = dom.getAttribute('data-mention-id')
const label = dom.innerText.split(this.options.matcher.char).join('')
return { id, label }
},
},
],
}
}
get plugins() {
return [
SuggestionsPlugin({
command: ({ range, attrs, schema }) => replaceText(range, schema.nodes.mention, attrs),
appendText: ' ',
matcher: this.options.matcher,
items: this.options.items,
onEnter: this.options.onEnter,
onChange: this.options.onChange,
onExit: this.options.onExit,
onKeyDown: this.options.onKeyDown,
onFilter: this.options.onFilter,
suggestionClass: this.options.suggestionClass,
}),
]
}
get plugins() {
return [
SuggestionsPlugin({
command: ({ range, attrs, schema }) => replaceText(range, schema.nodes.mention, attrs),
appendText: ' ',
matcher: this.options.matcher,
items: this.options.items,
onEnter: this.options.onEnter,
onChange: this.options.onChange,
onExit: this.options.onExit,
onKeyDown: this.options.onKeyDown,
onFilter: this.options.onFilter,
suggestionClass: this.options.suggestionClass,
}),
]
}
}

View File

@@ -3,50 +3,50 @@ import { wrappingInputRule, toggleList } from 'tiptap-commands'
export default class OrderedList extends Node {
get name() {
return 'ordered_list'
}
get name() {
return 'ordered_list'
}
get schema() {
return {
attrs: {
order: {
default: 1,
},
},
content: 'list_item+',
group: 'block',
parseDOM: [
{
tag: 'ol',
getAttrs: dom => ({
order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1,
}),
},
],
toDOM: node => (node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0]),
}
}
get schema() {
return {
attrs: {
order: {
default: 1,
},
},
content: 'list_item+',
group: 'block',
parseDOM: [
{
tag: 'ol',
getAttrs: dom => ({
order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1,
}),
},
],
toDOM: node => (node.attrs.order === 1 ? ['ol', 0] : ['ol', { start: node.attrs.order }, 0]),
}
}
commands({ type, schema }) {
return () => toggleList(type, schema.nodes.list_item)
}
commands({ type, schema }) {
return () => toggleList(type, schema.nodes.list_item)
}
keys({ type, schema }) {
return {
'Shift-Ctrl-9': toggleList(type, schema.nodes.list_item),
}
}
keys({ type, schema }) {
return {
'Shift-Ctrl-9': toggleList(type, schema.nodes.list_item),
}
}
inputRules({ type }) {
return [
wrappingInputRule(
/^(\d+)\.\s$/,
type,
match => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1],
),
]
}
inputRules({ type }) {
return [
wrappingInputRule(
/^(\d+)\.\s$/,
type,
match => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1],
),
]
}
}

View File

@@ -3,64 +3,64 @@ import { splitToDefaultListItem, liftListItem } from 'tiptap-commands'
export default class TodoItem extends Node {
get name() {
return 'todo_item'
}
get name() {
return 'todo_item'
}
get view() {
return {
props: ['node', 'updateAttrs', 'editable'],
methods: {
onChange() {
this.updateAttrs({
done: !this.node.attrs.done,
})
},
},
template: `
<li data-type="todo_item" :data-done="node.attrs.done.toString()">
<span class="todo-checkbox" contenteditable="false" @click="onChange"></span>
<div class="todo-content" ref="content" :contenteditable="editable.toString()"></div>
</li>
`,
}
}
get view() {
return {
props: ['node', 'updateAttrs', 'editable'],
methods: {
onChange() {
this.updateAttrs({
done: !this.node.attrs.done,
})
},
},
template: `
<li data-type="todo_item" :data-done="node.attrs.done.toString()">
<span class="todo-checkbox" contenteditable="false" @click="onChange"></span>
<div class="todo-content" ref="content" :contenteditable="editable.toString()"></div>
</li>
`,
}
}
get schema() {
return {
attrs: {
done: {
default: false,
},
},
draggable: false,
content: 'paragraph',
toDOM(node) {
const { done } = node.attrs
get schema() {
return {
attrs: {
done: {
default: false,
},
},
draggable: false,
content: 'paragraph',
toDOM(node) {
const { done } = node.attrs
return ['li', {
'data-type': 'todo_item',
'data-done': done.toString(),
},
['span', { class: 'todo-checkbox', contenteditable: 'false' }],
['div', { class: 'todo-content' }, 0],
]
},
parseDOM: [{
priority: 51,
tag: '[data-type="todo_item"]',
getAttrs: dom => ({
done: dom.getAttribute('data-done') === 'true',
}),
}],
}
}
return ['li', {
'data-type': 'todo_item',
'data-done': done.toString(),
},
['span', { class: 'todo-checkbox', contenteditable: 'false' }],
['div', { class: 'todo-content' }, 0],
]
},
parseDOM: [{
priority: 51,
tag: '[data-type="todo_item"]',
getAttrs: dom => ({
done: dom.getAttribute('data-done') === 'true',
}),
}],
}
}
keys({ type }) {
return {
Enter: splitToDefaultListItem(type),
'Shift-Tab': liftListItem(type),
}
}
keys({ type }) {
return {
Enter: splitToDefaultListItem(type),
'Shift-Tab': liftListItem(type),
}
}
}

View File

@@ -3,30 +3,30 @@ import { wrapInList, wrappingInputRule } from 'tiptap-commands'
export default class TodoList extends Node {
get name() {
return 'todo_list'
}
get name() {
return 'todo_list'
}
get schema() {
return {
group: 'block',
content: 'todo_item+',
toDOM: () => ['ul', { 'data-type': 'todo_list' }, 0],
parseDOM: [{
priority: 51,
tag: '[data-type="todo_list"]',
}],
}
}
get schema() {
return {
group: 'block',
content: 'todo_item+',
toDOM: () => ['ul', { 'data-type': 'todo_list' }, 0],
parseDOM: [{
priority: 51,
tag: '[data-type="todo_list"]',
}],
}
}
commands({ type }) {
return () => wrapInList(type)
}
commands({ type }) {
return () => wrapInList(type)
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*(\[ \])\s$/, type),
]
}
inputRules({ type }) {
return [
wrappingInputRule(/^\s*(\[ \])\s$/, type),
]
}
}

View File

@@ -4,239 +4,239 @@ import { insertText } from 'tiptap-commands'
// Create a matcher that matches when a specific character is typed. Useful for @mentions and #tags.
function triggerCharacter({
char = '@',
allowSpaces = false,
startOfLine = false,
char = '@',
allowSpaces = false,
startOfLine = false,
}) {
return $position => {
// Matching expressions used for later
const suffix = new RegExp(`\\s${char}$`)
const prefix = startOfLine ? '^' : ''
const regexp = allowSpaces
? new RegExp(`${prefix}${char}.*?(?=\\s${char}|$)`, 'gm')
: new RegExp(`${prefix}(?:^)?${char}[^\\s${char}]*`, 'gm')
return $position => {
// Matching expressions used for later
const suffix = new RegExp(`\\s${char}$`)
const prefix = startOfLine ? '^' : ''
const regexp = allowSpaces
? new RegExp(`${prefix}${char}.*?(?=\\s${char}|$)`, 'gm')
: new RegExp(`${prefix}(?:^)?${char}[^\\s${char}]*`, 'gm')
// Lookup the boundaries of the current node
const textFrom = $position.before()
const textTo = $position.end()
const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0')
// Lookup the boundaries of the current node
const textFrom = $position.before()
const textTo = $position.end()
const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0')
let match = regexp.exec(text)
let position
while (match !== null) {
// JavaScript doesn't have lookbehinds; this hacks a check that first character is " "
// or the line beginning
const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)
let match = regexp.exec(text)
let position
while (match !== null) {
// JavaScript doesn't have lookbehinds; this hacks a check that first character is " "
// or the line beginning
const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)
if (/^[\s\0]?$/.test(matchPrefix)) {
// The absolute position of the match in the document
const from = match.index + $position.start()
let to = from + match[0].length
if (/^[\s\0]?$/.test(matchPrefix)) {
// The absolute position of the match in the document
const from = match.index + $position.start()
let to = from + match[0].length
// Edge case handling; if spaces are allowed and we're directly in between
// two triggers
if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {
match[0] += ' '
to += 1
}
// Edge case handling; if spaces are allowed and we're directly in between
// two triggers
if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {
match[0] += ' '
to += 1
}
// If the $position is located within the matched substring, return that range
if (from < $position.pos && to >= $position.pos) {
position = {
range: {
from,
to,
},
query: match[0].slice(char.length),
text: match[0],
}
}
}
// If the $position is located within the matched substring, return that range
if (from < $position.pos && to >= $position.pos) {
position = {
range: {
from,
to,
},
query: match[0].slice(char.length),
text: match[0],
}
}
}
match = regexp.exec(text)
}
}
return position
}
}
}
export default function SuggestionsPlugin({
matcher = {
char: '@',
allowSpaces: false,
startOfLine: false,
},
appendText = null,
suggestionClass = 'suggestion',
command = () => false,
items = [],
onEnter = () => false,
onChange = () => false,
onExit = () => false,
onKeyDown = () => false,
onFilter = (searchItems, query) => {
if (!query) {
return searchItems
}
matcher = {
char: '@',
allowSpaces: false,
startOfLine: false,
},
appendText = null,
suggestionClass = 'suggestion',
command = () => false,
items = [],
onEnter = () => false,
onChange = () => false,
onExit = () => false,
onKeyDown = () => false,
onFilter = (searchItems, query) => {
if (!query) {
return searchItems
}
return searchItems
.filter(item => JSON.stringify(item).toLowerCase().includes(query.toLowerCase()))
},
return searchItems
.filter(item => JSON.stringify(item).toLowerCase().includes(query.toLowerCase()))
},
}) {
return new Plugin({
key: new PluginKey('suggestions'),
return new Plugin({
key: new PluginKey('suggestions'),
view() {
return {
update: (view, prevState) => {
const prev = this.key.getState(prevState)
const next = this.key.getState(view.state)
view() {
return {
update: (view, prevState) => {
const prev = this.key.getState(prevState)
const next = this.key.getState(view.state)
// See how the state changed
const moved = prev.active && next.active && prev.range.from !== next.range.from
const started = !prev.active && next.active
const stopped = prev.active && !next.active
const changed = !started && !stopped && prev.query !== next.query
const handleStart = started || moved
const handleChange = changed && !moved
const handleExit = stopped || moved
// See how the state changed
const moved = prev.active && next.active && prev.range.from !== next.range.from
const started = !prev.active && next.active
const stopped = prev.active && !next.active
const changed = !started && !stopped && prev.query !== next.query
const handleStart = started || moved
const handleChange = changed && !moved
const handleExit = stopped || moved
// Cancel when suggestion isn't active
if (!handleStart && !handleChange && !handleExit) {
return
}
// Cancel when suggestion isn't active
if (!handleStart && !handleChange && !handleExit) {
return
}
const state = handleExit ? prev : next
const decorationNode = document.querySelector(`[data-decoration-id="${state.decorationId}"]`)
const state = handleExit ? prev : next
const decorationNode = document.querySelector(`[data-decoration-id="${state.decorationId}"]`)
// build a virtual node for popper.js or tippy.js
// this can be used for building popups without a DOM node
const virtualNode = decorationNode ? {
getBoundingClientRect() {
return decorationNode.getBoundingClientRect()
},
clientWidth: decorationNode.clientWidth,
clientHeight: decorationNode.clientHeight,
} : null
// build a virtual node for popper.js or tippy.js
// this can be used for building popups without a DOM node
const virtualNode = decorationNode ? {
getBoundingClientRect() {
return decorationNode.getBoundingClientRect()
},
clientWidth: decorationNode.clientWidth,
clientHeight: decorationNode.clientHeight,
} : null
const props = {
view,
range: state.range,
query: state.query,
text: state.text,
decorationNode,
virtualNode,
items: onFilter(Array.isArray(items) ? items : items(), state.query),
command: ({ range, attrs }) => {
command({
range,
attrs,
schema: view.state.schema,
})(view.state, view.dispatch, view)
const props = {
view,
range: state.range,
query: state.query,
text: state.text,
decorationNode,
virtualNode,
items: onFilter(Array.isArray(items) ? items : items(), state.query),
command: ({ range, attrs }) => {
command({
range,
attrs,
schema: view.state.schema,
})(view.state, view.dispatch, view)
if (appendText) {
insertText(appendText)(view.state, view.dispatch, view)
}
},
}
if (appendText) {
insertText(appendText)(view.state, view.dispatch, view)
}
},
}
// Trigger the hooks when necessary
if (handleExit) {
onExit(props)
}
// Trigger the hooks when necessary
if (handleExit) {
onExit(props)
}
if (handleChange) {
onChange(props)
}
if (handleChange) {
onChange(props)
}
if (handleStart) {
onEnter(props)
}
},
}
},
if (handleStart) {
onEnter(props)
}
},
}
},
state: {
state: {
// Initialize the plugin's internal state.
init() {
return {
active: false,
range: {},
query: null,
text: null,
}
},
// Initialize the plugin's internal state.
init() {
return {
active: false,
range: {},
query: null,
text: null,
}
},
// Apply changes to the plugin state from a view transaction.
apply(tr, prev) {
const { selection } = tr
const next = { ...prev }
// Apply changes to the plugin state from a view transaction.
apply(tr, prev) {
const { selection } = tr
const next = { ...prev }
// We can only be suggesting if there is no selection
if (selection.from === selection.to) {
// Reset active state if we just left the previous suggestion range
if (selection.from < prev.range.from || selection.from > prev.range.to) {
next.active = false
}
// We can only be suggesting if there is no selection
if (selection.from === selection.to) {
// Reset active state if we just left the previous suggestion range
if (selection.from < prev.range.from || selection.from > prev.range.to) {
next.active = false
}
// Try to match against where our cursor currently is
const $position = selection.$from
const match = triggerCharacter(matcher)($position)
const decorationId = (Math.random() + 1).toString(36).substr(2, 5)
// Try to match against where our cursor currently is
const $position = selection.$from
const match = triggerCharacter(matcher)($position)
const decorationId = (Math.random() + 1).toString(36).substr(2, 5)
// If we found a match, update the current state to show it
if (match) {
next.active = true
next.decorationId = prev.decorationId ? prev.decorationId : decorationId
next.range = match.range
next.query = match.query
next.text = match.text
} else {
next.active = false
}
} else {
next.active = false
}
// If we found a match, update the current state to show it
if (match) {
next.active = true
next.decorationId = prev.decorationId ? prev.decorationId : decorationId
next.range = match.range
next.query = match.query
next.text = match.text
} else {
next.active = false
}
} else {
next.active = false
}
// Make sure to empty the range if suggestion is inactive
if (!next.active) {
next.decorationId = null
next.range = {}
next.query = null
next.text = null
}
// Make sure to empty the range if suggestion is inactive
if (!next.active) {
next.decorationId = null
next.range = {}
next.query = null
next.text = null
}
return next
},
},
return next
},
},
props: {
props: {
// Call the keydown hook if suggestion is active.
handleKeyDown(view, event) {
const { active, range } = this.getState(view.state)
// Call the keydown hook if suggestion is active.
handleKeyDown(view, event) {
const { active, range } = this.getState(view.state)
if (!active) return false
if (!active) return false
return onKeyDown({ view, event, range })
},
return onKeyDown({ view, event, range })
},
// Setup decorator on the currently active suggestion.
decorations(editorState) {
const { active, range, decorationId } = this.getState(editorState)
// Setup decorator on the currently active suggestion.
decorations(editorState) {
const { active, range, decorationId } = this.getState(editorState)
if (!active) return null
if (!active) return null
return DecorationSet.create(editorState.doc, [
Decoration.inline(range.from, range.to, {
nodeName: 'span',
class: suggestionClass,
'data-decoration-id': decorationId,
}),
])
},
},
})
return DecorationSet.create(editorState.doc, [
Decoration.inline(range.from, range.to, {
nodeName: 'span',
class: suggestionClass,
'data-decoration-id': decorationId,
}),
])
},
},
})
}

View File

@@ -1,16 +1,16 @@
export default function (state, type) {
const { from, to } = state.selection
let marks = []
const { from, to } = state.selection
let marks = []
state.doc.nodesBetween(from, to, node => {
marks = [...marks, ...node.marks]
})
state.doc.nodesBetween(from, to, node => {
marks = [...marks, ...node.marks]
})
const mark = marks.find(markItem => markItem.type.name === type.name)
const mark = marks.find(markItem => markItem.type.name === type.name)
if (mark) {
return mark.attrs
}
if (mark) {
return mark.attrs
}
return {}
return {}
}

View File

@@ -1,14 +1,14 @@
export default function (state, type) {
const {
from,
$from,
to,
empty,
} = state.selection
const {
from,
$from,
to,
empty,
} = state.selection
if (empty) {
return !!type.isInSet(state.storedMarks || $from.marks())
}
if (empty) {
return !!type.isInSet(state.storedMarks || $from.marks())
}
return !!state.doc.rangeHasMark(from, to, type)
return !!state.doc.rangeHasMark(from, to, type)
}

View File

@@ -1,12 +1,12 @@
import { findParentNode } from 'prosemirror-utils'
export default function (state, type, attrs = {}) {
const predicate = node => node.type === type
const parent = findParentNode(predicate)(state.selection)
const predicate = node => node.type === type
const parent = findParentNode(predicate)(state.selection)
if (!Object.keys(attrs).length || !parent) {
return !!parent
}
if (!Object.keys(attrs).length || !parent) {
return !!parent
}
return parent.node.hasMarkup(type, attrs)
return parent.node.hasMarkup(type, attrs)
}

View File

@@ -1,21 +1,21 @@
export default {
props: {
editor: {
default: null,
type: Object,
},
},
watch: {
'editor.element': {
immediate: true,
handler(element) {
if (element) {
this.$nextTick(() => this.$el.append(element.firstChild))
}
},
},
},
render(createElement) {
return createElement('div')
},
props: {
editor: {
default: null,
type: Object,
},
},
watch: {
'editor.element': {
immediate: true,
handler(element) {
if (element) {
this.$nextTick(() => this.$el.append(element.firstChild))
}
},
},
},
render(createElement) {
return createElement('div')
},
}

View File

@@ -1,50 +1,50 @@
import FloatingMenu from '../Utils/FloatingMenu'
export default {
props: {
editor: {
default: null,
type: Object,
},
},
data() {
return {
menu: {
isActive: false,
left: 0,
bottom: 0,
},
}
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor) {
this.$nextTick(() => {
editor.registerPlugin(FloatingMenu({
element: this.$el,
onUpdate: menu => {
this.menu = menu
},
}))
})
}
},
},
},
render() {
if (!this.editor) {
return null
}
props: {
editor: {
default: null,
type: Object,
},
},
data() {
return {
menu: {
isActive: false,
left: 0,
bottom: 0,
},
}
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor) {
this.$nextTick(() => {
editor.registerPlugin(FloatingMenu({
element: this.$el,
onUpdate: menu => {
this.menu = menu
},
}))
})
}
},
},
},
render() {
if (!this.editor) {
return null
}
return this.$scopedSlots.default({
focused: this.editor.view.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive.bind(this.editor),
markAttrs: this.editor.markAttrs.bind(this.editor),
menu: this.menu,
})
},
return this.$scopedSlots.default({
focused: this.editor.view.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive.bind(this.editor),
markAttrs: this.editor.markAttrs.bind(this.editor),
menu: this.menu,
})
},
}

View File

@@ -1,21 +1,21 @@
export default {
props: {
editor: {
default: null,
type: Object,
},
},
render() {
if (!this.editor) {
return null
}
props: {
editor: {
default: null,
type: Object,
},
},
render() {
if (!this.editor) {
return null
}
return this.$scopedSlots.default({
focused: this.editor.view.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive.bind(this.editor),
markAttrs: this.editor.markAttrs.bind(this.editor),
})
},
return this.$scopedSlots.default({
focused: this.editor.view.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive.bind(this.editor),
markAttrs: this.editor.markAttrs.bind(this.editor),
})
},
}

View File

@@ -1,50 +1,50 @@
import MenuBubble from '../Utils/MenuBubble'
export default {
props: {
editor: {
default: null,
type: Object,
},
},
data() {
return {
menu: {
isActive: false,
left: 0,
bottom: 0,
},
}
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor) {
this.$nextTick(() => {
editor.registerPlugin(MenuBubble({
element: this.$el,
onUpdate: menu => {
this.menu = menu
},
}))
})
}
},
},
},
render() {
if (!this.editor) {
return null
}
props: {
editor: {
default: null,
type: Object,
},
},
data() {
return {
menu: {
isActive: false,
left: 0,
bottom: 0,
},
}
},
watch: {
editor: {
immediate: true,
handler(editor) {
if (editor) {
this.$nextTick(() => {
editor.registerPlugin(MenuBubble({
element: this.$el,
onUpdate: menu => {
this.menu = menu
},
}))
})
}
},
},
},
render() {
if (!this.editor) {
return null
}
return this.$scopedSlots.default({
focused: this.editor.view.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive.bind(this.editor),
markAttrs: this.editor.markAttrs.bind(this.editor),
menu: this.menu,
})
},
return this.$scopedSlots.default({
focused: this.editor.view.focused,
focus: this.editor.focus,
commands: this.editor.commands,
isActive: this.editor.isActive.bind(this.editor),
markAttrs: this.editor.markAttrs.bind(this.editor),
menu: this.menu,
})
},
}

View File

@@ -2,14 +2,14 @@ import Node from '../Utils/Node'
export default class Doc extends Node {
get name() {
return 'doc'
}
get name() {
return 'doc'
}
get schema() {
return {
content: 'block+',
}
}
get schema() {
return {
content: 'block+',
}
}
}

View File

@@ -3,24 +3,24 @@ import Node from '../Utils/Node'
export default class Paragraph extends Node {
get name() {
return 'paragraph'
}
get name() {
return 'paragraph'
}
get schema() {
return {
content: 'inline*',
group: 'block',
draggable: false,
parseDOM: [{
tag: 'p',
}],
toDOM: () => ['p', 0],
}
}
get schema() {
return {
content: 'inline*',
group: 'block',
draggable: false,
parseDOM: [{
tag: 'p',
}],
toDOM: () => ['p', 0],
}
}
commands({ type }) {
return () => setBlockType(type)
}
commands({ type }) {
return () => setBlockType(type)
}
}

View File

@@ -2,14 +2,14 @@ import Node from '../Utils/Node'
export default class Text extends Node {
get name() {
return 'text'
}
get name() {
return 'text'
}
get schema() {
return {
group: 'inline',
}
}
get schema() {
return {
group: 'inline',
}
}
}

View File

@@ -3,7 +3,7 @@ import Paragraph from './Paragraph'
import Text from './Text'
export default [
new Doc(),
new Text(),
new Paragraph(),
new Doc(),
new Text(),
new Paragraph(),
]

View File

@@ -1,92 +1,92 @@
import Vue from 'vue'
export default class ComponentView {
constructor(component, {
parent,
node,
view,
getPos,
decorations,
editable,
}) {
this.parent = parent
this.component = component
this.node = node
this.view = view
this.getPos = getPos
this.decorations = decorations
this.editable = editable
constructor(component, {
parent,
node,
view,
getPos,
decorations,
editable,
}) {
this.parent = parent
this.component = component
this.node = node
this.view = view
this.getPos = getPos
this.decorations = decorations
this.editable = editable
this.dom = this.createDOM()
this.contentDOM = this.vm.$refs.content
}
this.dom = this.createDOM()
this.contentDOM = this.vm.$refs.content
}
createDOM() {
const Component = Vue.extend(this.component)
this.vm = new Component({
parent: this.parent,
propsData: {
node: this.node,
view: this.view,
getPos: this.getPos,
decorations: this.decorations,
editable: this.editable,
updateAttrs: attrs => this.updateAttrs(attrs),
updateContent: content => this.updateContent(content),
},
}).$mount()
return this.vm.$el
}
createDOM() {
const Component = Vue.extend(this.component)
this.vm = new Component({
parent: this.parent,
propsData: {
node: this.node,
view: this.view,
getPos: this.getPos,
decorations: this.decorations,
editable: this.editable,
updateAttrs: attrs => this.updateAttrs(attrs),
updateContent: content => this.updateContent(content),
},
}).$mount()
return this.vm.$el
}
updateAttrs(attrs) {
if (!this.editable) {
return
}
updateAttrs(attrs) {
if (!this.editable) {
return
}
const transaction = this.view.state.tr.setNodeMarkup(this.getPos(), null, {
...this.node.attrs,
...attrs,
})
this.view.dispatch(transaction)
}
const transaction = this.view.state.tr.setNodeMarkup(this.getPos(), null, {
...this.node.attrs,
...attrs,
})
this.view.dispatch(transaction)
}
updateContent(content) {
if (!this.editable) {
return
}
updateContent(content) {
if (!this.editable) {
return
}
const transaction = this.view.state.tr.setNodeMarkup(this.getPos(), this.node.type, { content })
this.view.dispatch(transaction)
}
const transaction = this.view.state.tr.setNodeMarkup(this.getPos(), this.node.type, { content })
this.view.dispatch(transaction)
}
ignoreMutation() {
return true
}
ignoreMutation() {
return true
}
stopEvent(event) {
// TODO: find a way to pass full extensions to ComponentView
// so we could check for schema.draggable
// for now we're allowing all drag events for node views
return !/drag/.test(event.type)
}
stopEvent(event) {
// TODO: find a way to pass full extensions to ComponentView
// so we could check for schema.draggable
// for now we're allowing all drag events for node views
return !/drag/.test(event.type)
}
update(node, decorations) {
if (node.type !== this.node.type) {
return false
}
update(node, decorations) {
if (node.type !== this.node.type) {
return false
}
if (node === this.node && this.decorations === decorations) {
return true
}
if (node === this.node && this.decorations === decorations) {
return true
}
this.node = node
this.decorations = decorations
this.vm._props.node = node
this.vm._props.decorations = decorations
return true
}
this.node = node
this.decorations = decorations
this.vm._props.node = node
this.vm._props.decorations = decorations
return true
}
destroy() {
this.vm.$destroy()
}
destroy() {
this.vm.$destroy()
}
}

View File

@@ -9,278 +9,278 @@ import { inputRules } from 'prosemirror-inputrules'
import { markIsActive, nodeIsActive, getMarkAttrs } from 'tiptap-utils'
import {
ExtensionManager,
initNodeViews,
builtInKeymap,
ExtensionManager,
initNodeViews,
builtInKeymap,
} from '.'
import builtInNodes from '../Nodes'
export default class Editor {
constructor(options = {}) {
this.setOptions(options)
this.init()
}
constructor(options = {}) {
this.setOptions(options)
this.init()
}
setOptions(options) {
const defaultOptions = {
editable: true,
content: '',
onUpdate: () => {},
}
setOptions(options) {
const defaultOptions = {
editable: true,
content: '',
onUpdate: () => {},
}
this.options = {
...defaultOptions,
...options,
}
}
this.options = {
...defaultOptions,
...options,
}
}
init() {
this.bus = new Vue()
this.element = document.createElement('div')
this.extensions = this.createExtensions()
this.nodes = this.createNodes()
this.marks = this.createMarks()
this.views = this.createViews()
this.schema = this.createSchema()
this.plugins = this.createPlugins()
this.keymaps = this.createKeymaps()
this.inputRules = this.createInputRules()
this.state = this.createState()
this.view = this.createView()
this.commands = this.createCommands()
this.getActiveNodesAndMarks()
this.emit('init')
}
init() {
this.bus = new Vue()
this.element = document.createElement('div')
this.extensions = this.createExtensions()
this.nodes = this.createNodes()
this.marks = this.createMarks()
this.views = this.createViews()
this.schema = this.createSchema()
this.plugins = this.createPlugins()
this.keymaps = this.createKeymaps()
this.inputRules = this.createInputRules()
this.state = this.createState()
this.view = this.createView()
this.commands = this.createCommands()
this.getActiveNodesAndMarks()
this.emit('init')
}
createExtensions() {
return new ExtensionManager([
...builtInNodes,
...this.options.extensions,
])
}
createExtensions() {
return new ExtensionManager([
...builtInNodes,
...this.options.extensions,
])
}
createPlugins() {
return this.extensions.plugins
}
createPlugins() {
return this.extensions.plugins
}
createKeymaps() {
return this.extensions.keymaps({
schema: this.schema,
})
}
createKeymaps() {
return this.extensions.keymaps({
schema: this.schema,
})
}
createInputRules() {
return this.extensions.inputRules({
schema: this.schema,
})
}
createInputRules() {
return this.extensions.inputRules({
schema: this.schema,
})
}
createCommands() {
return this.extensions.commands({
schema: this.schema,
view: this.view,
editable: this.options.editable,
})
}
createCommands() {
return this.extensions.commands({
schema: this.schema,
view: this.view,
editable: this.options.editable,
})
}
createNodes() {
return this.extensions.nodes
}
createNodes() {
return this.extensions.nodes
}
createMarks() {
return this.extensions.marks
}
createMarks() {
return this.extensions.marks
}
createViews() {
return this.extensions.views
}
createViews() {
return this.extensions.views
}
createSchema() {
return new Schema({
nodes: this.nodes,
marks: this.marks,
})
}
createSchema() {
return new Schema({
nodes: this.nodes,
marks: this.marks,
})
}
createState() {
return EditorState.create({
schema: this.schema,
doc: this.createDocument(this.options.content),
plugins: [
...this.plugins,
inputRules({
rules: this.inputRules,
}),
...this.keymaps,
keymap(builtInKeymap),
keymap(baseKeymap),
gapCursor(),
new Plugin({
props: {
editable: () => this.options.editable,
},
}),
],
})
}
createState() {
return EditorState.create({
schema: this.schema,
doc: this.createDocument(this.options.content),
plugins: [
...this.plugins,
inputRules({
rules: this.inputRules,
}),
...this.keymaps,
keymap(builtInKeymap),
keymap(baseKeymap),
gapCursor(),
new Plugin({
props: {
editable: () => this.options.editable,
},
}),
],
})
}
createDocument(content) {
if (typeof content === 'object') {
return this.schema.nodeFromJSON(content)
}
createDocument(content) {
if (typeof content === 'object') {
return this.schema.nodeFromJSON(content)
}
if (typeof content === 'string') {
const element = document.createElement('div')
element.innerHTML = content.trim()
if (typeof content === 'string') {
const element = document.createElement('div')
element.innerHTML = content.trim()
return DOMParser.fromSchema(this.schema).parse(element)
}
return DOMParser.fromSchema(this.schema).parse(element)
}
return false
}
return false
}
createView() {
const view = new EditorView(this.element, {
state: this.state,
dispatchTransaction: this.dispatchTransaction.bind(this),
nodeViews: initNodeViews({
nodes: this.views,
editable: this.options.editable,
}),
})
createView() {
const view = new EditorView(this.element, {
state: this.state,
dispatchTransaction: this.dispatchTransaction.bind(this),
nodeViews: initNodeViews({
nodes: this.views,
editable: this.options.editable,
}),
})
view.dom.style.whiteSpace = 'pre-wrap'
view.dom.style.whiteSpace = 'pre-wrap'
return view
}
return view
}
dispatchTransaction(transaction) {
this.state = this.state.apply(transaction)
this.view.updateState(this.state)
this.getActiveNodesAndMarks()
dispatchTransaction(transaction) {
this.state = this.state.apply(transaction)
this.view.updateState(this.state)
this.getActiveNodesAndMarks()
if (!transaction.docChanged) {
return
}
if (!transaction.docChanged) {
return
}
this.emitUpdate()
}
this.emitUpdate()
}
emitUpdate() {
this.options.onUpdate({
getHTML: this.getHTML.bind(this),
getJSON: this.getJSON.bind(this),
state: this.state,
})
}
emitUpdate() {
this.options.onUpdate({
getHTML: this.getHTML.bind(this),
getJSON: this.getJSON.bind(this),
state: this.state,
})
}
getHTML() {
const div = document.createElement('div')
const fragment = DOMSerializer
.fromSchema(this.schema)
.serializeFragment(this.state.doc.content)
getHTML() {
const div = document.createElement('div')
const fragment = DOMSerializer
.fromSchema(this.schema)
.serializeFragment(this.state.doc.content)
div.appendChild(fragment)
div.appendChild(fragment)
return div.innerHTML
}
return div.innerHTML
}
getJSON() {
return this.state.doc.toJSON()
}
getJSON() {
return this.state.doc.toJSON()
}
setContent(content = {}, emitUpdate = false) {
this.state = EditorState.create({
schema: this.state.schema,
doc: this.createDocument(content),
plugins: this.state.plugins,
})
setContent(content = {}, emitUpdate = false) {
this.state = EditorState.create({
schema: this.state.schema,
doc: this.createDocument(content),
plugins: this.state.plugins,
})
this.view.updateState(this.state)
this.view.updateState(this.state)
if (emitUpdate) {
this.emitUpdate()
}
}
if (emitUpdate) {
this.emitUpdate()
}
}
clearContent(emitUpdate = false) {
this.setContent({
type: 'doc',
content: [{
type: 'paragraph',
}],
}, emitUpdate)
}
clearContent(emitUpdate = false) {
this.setContent({
type: 'doc',
content: [{
type: 'paragraph',
}],
}, emitUpdate)
}
getActiveNodesAndMarks() {
this.activeMarks = Object
.entries(this.schema.marks)
.reduce((marks, [name, mark]) => ({
...marks,
[name]: (attrs = {}) => markIsActive(this.state, mark, attrs),
}), {})
getActiveNodesAndMarks() {
this.activeMarks = Object
.entries(this.schema.marks)
.reduce((marks, [name, mark]) => ({
...marks,
[name]: (attrs = {}) => markIsActive(this.state, mark, attrs),
}), {})
this.activeMarkAttrs = Object
.entries(this.schema.marks)
.reduce((marks, [name, mark]) => ({
...marks,
[name]: getMarkAttrs(this.state, mark),
}), {})
this.activeMarkAttrs = Object
.entries(this.schema.marks)
.reduce((marks, [name, mark]) => ({
...marks,
[name]: getMarkAttrs(this.state, mark),
}), {})
this.activeNodes = Object
.entries(this.schema.nodes)
.reduce((nodes, [name, node]) => ({
...nodes,
[name]: (attrs = {}) => nodeIsActive(this.state, node, attrs),
}), {})
}
this.activeNodes = Object
.entries(this.schema.nodes)
.reduce((nodes, [name, node]) => ({
...nodes,
[name]: (attrs = {}) => nodeIsActive(this.state, node, attrs),
}), {})
}
focus() {
this.view.focus()
}
focus() {
this.view.focus()
}
emit(event, ...data) {
this.bus.$emit(event, ...data)
}
emit(event, ...data) {
this.bus.$emit(event, ...data)
}
on(event, callback) {
this.bus.$on(event, callback)
}
on(event, callback) {
this.bus.$on(event, callback)
}
registerPlugin(plugin = null) {
if (plugin) {
this.state = this.state.reconfigure({
plugins: this.state.plugins.concat([plugin]),
})
this.view.updateState(this.state)
}
}
registerPlugin(plugin = null) {
if (plugin) {
this.state = this.state.reconfigure({
plugins: this.state.plugins.concat([plugin]),
})
this.view.updateState(this.state)
}
}
markAttrs(type = null) {
return this.activeMarkAttrs[type]
}
markAttrs(type = null) {
return this.activeMarkAttrs[type]
}
isActive(type = null, attrs = {}) {
const types = {
...this.activeMarks,
...this.activeNodes,
}
isActive(type = null, attrs = {}) {
const types = {
...this.activeMarks,
...this.activeNodes,
}
if (!types[type]) {
return false
}
if (!types[type]) {
return false
}
return types[type](attrs)
}
return types[type](attrs)
}
destroy() {
this.emit('destroy')
destroy() {
this.emit('destroy')
if (this.view) {
this.view.destroy()
}
}
if (this.view) {
this.view.destroy()
}
}
}

View File

@@ -2,148 +2,148 @@ import { keymap } from 'prosemirror-keymap'
export default class ExtensionManager {
constructor(extensions = []) {
this.extensions = extensions
}
constructor(extensions = []) {
this.extensions = extensions
}
get nodes() {
return this.extensions
.filter(extension => extension.type === 'node')
.reduce((nodes, { name, schema }) => ({
...nodes,
[name]: schema,
}), {})
}
get nodes() {
return this.extensions
.filter(extension => extension.type === 'node')
.reduce((nodes, { name, schema }) => ({
...nodes,
[name]: schema,
}), {})
}
get marks() {
return this.extensions
.filter(extension => extension.type === 'mark')
.reduce((marks, { name, schema }) => ({
...marks,
[name]: schema,
}), {})
}
get marks() {
return this.extensions
.filter(extension => extension.type === 'mark')
.reduce((marks, { name, schema }) => ({
...marks,
[name]: schema,
}), {})
}
get plugins() {
return this.extensions
.filter(extension => extension.plugins)
.reduce((allPlugins, { plugins }) => ([
...allPlugins,
...plugins,
]), [])
}
get plugins() {
return this.extensions
.filter(extension => extension.plugins)
.reduce((allPlugins, { plugins }) => ([
...allPlugins,
...plugins,
]), [])
}
get views() {
return this.extensions
.filter(extension => ['node', 'mark'].includes(extension.type))
.filter(extension => extension.view)
.reduce((views, { name, view }) => ({
...views,
[name]: view,
}), {})
}
get views() {
return this.extensions
.filter(extension => ['node', 'mark'].includes(extension.type))
.filter(extension => extension.view)
.reduce((views, { name, view }) => ({
...views,
[name]: view,
}), {})
}
keymaps({ schema }) {
const extensionKeymaps = this.extensions
.filter(extension => ['extension'].includes(extension.type))
.filter(extension => extension.keys)
.map(extension => extension.keys({ schema }))
keymaps({ schema }) {
const extensionKeymaps = this.extensions
.filter(extension => ['extension'].includes(extension.type))
.filter(extension => extension.keys)
.map(extension => extension.keys({ schema }))
const nodeMarkKeymaps = this.extensions
.filter(extension => ['node', 'mark'].includes(extension.type))
.filter(extension => extension.keys)
.map(extension => extension.keys({
type: schema[`${extension.type}s`][extension.name],
schema,
}))
const nodeMarkKeymaps = this.extensions
.filter(extension => ['node', 'mark'].includes(extension.type))
.filter(extension => extension.keys)
.map(extension => extension.keys({
type: schema[`${extension.type}s`][extension.name],
schema,
}))
return [
...extensionKeymaps,
...nodeMarkKeymaps,
].map(keys => keymap(keys))
}
return [
...extensionKeymaps,
...nodeMarkKeymaps,
].map(keys => keymap(keys))
}
inputRules({ schema }) {
const extensionInputRules = this.extensions
.filter(extension => ['extension'].includes(extension.type))
.filter(extension => extension.inputRules)
.map(extension => extension.inputRules({ schema }))
inputRules({ schema }) {
const extensionInputRules = this.extensions
.filter(extension => ['extension'].includes(extension.type))
.filter(extension => extension.inputRules)
.map(extension => extension.inputRules({ schema }))
const nodeMarkInputRules = this.extensions
.filter(extension => ['node', 'mark'].includes(extension.type))
.filter(extension => extension.inputRules)
.map(extension => extension.inputRules({
type: schema[`${extension.type}s`][extension.name],
schema,
}))
const nodeMarkInputRules = this.extensions
.filter(extension => ['node', 'mark'].includes(extension.type))
.filter(extension => extension.inputRules)
.map(extension => extension.inputRules({
type: schema[`${extension.type}s`][extension.name],
schema,
}))
return [
...extensionInputRules,
...nodeMarkInputRules,
].reduce((allInputRules, inputRules) => ([
...allInputRules,
...inputRules,
]), [])
}
return [
...extensionInputRules,
...nodeMarkInputRules,
].reduce((allInputRules, inputRules) => ([
...allInputRules,
...inputRules,
]), [])
}
commands({ schema, view, editable }) {
return this.extensions
.filter(extension => extension.commands)
.reduce((allCommands, { name, type, commands: provider }) => {
commands({ schema, view, editable }) {
return this.extensions
.filter(extension => extension.commands)
.reduce((allCommands, { name, type, commands: provider }) => {
const commands = {}
const value = provider({
schema,
...['node', 'mark'].includes(type) ? {
type: schema[`${type}s`][name],
} : {},
})
const commands = {}
const value = provider({
schema,
...['node', 'mark'].includes(type) ? {
type: schema[`${type}s`][name],
} : {},
})
if (Array.isArray(value)) {
commands[name] = attrs => value
.forEach(callback => {
if (!editable) {
return false
}
view.focus()
return callback(attrs)(view.state, view.dispatch, view)
})
} else if (typeof value === 'function') {
commands[name] = attrs => {
if (!editable) {
return false
}
view.focus()
return value(attrs)(view.state, view.dispatch, view)
}
} else if (typeof value === 'object') {
Object.entries(value).forEach(([commandName, commandValue]) => {
if (Array.isArray(commandValue)) {
commands[commandName] = attrs => commandValue
.forEach(callback => {
if (!editable) {
return false
}
view.focus()
return callback(attrs)(view.state, view.dispatch, view)
})
} else {
commands[commandName] = attrs => {
if (!editable) {
return false
}
view.focus()
return commandValue(attrs)(view.state, view.dispatch, view)
}
}
})
}
if (Array.isArray(value)) {
commands[name] = attrs => value
.forEach(callback => {
if (!editable) {
return false
}
view.focus()
return callback(attrs)(view.state, view.dispatch, view)
})
} else if (typeof value === 'function') {
commands[name] = attrs => {
if (!editable) {
return false
}
view.focus()
return value(attrs)(view.state, view.dispatch, view)
}
} else if (typeof value === 'object') {
Object.entries(value).forEach(([commandName, commandValue]) => {
if (Array.isArray(commandValue)) {
commands[commandName] = attrs => commandValue
.forEach(callback => {
if (!editable) {
return false
}
view.focus()
return callback(attrs)(view.state, view.dispatch, view)
})
} else {
commands[commandName] = attrs => {
if (!editable) {
return false
}
view.focus()
return commandValue(attrs)(view.state, view.dispatch, view)
}
}
})
}
return {
...allCommands,
...commands,
}
}, {})
}
return {
...allCommands,
...commands,
}
}, {})
}
}

View File

@@ -2,81 +2,81 @@ import { Plugin } from 'prosemirror-state'
class Menu {
constructor({ options, editorView }) {
this.options = {
...{
element: null,
onUpdate: () => false,
},
...options,
}
this.editorView = editorView
this.isActive = false
this.top = 0
constructor({ options, editorView }) {
this.options = {
...{
element: null,
onUpdate: () => false,
},
...options,
}
this.editorView = editorView
this.isActive = false
this.top = 0
this.editorView.dom.addEventListener('blur', this.hide.bind(this))
}
this.editorView.dom.addEventListener('blur', this.hide.bind(this))
}
update(view, lastState) {
const { state } = view
update(view, lastState) {
const { state } = view
// Don't do anything if the document/selection didn't change
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return
}
// Don't do anything if the document/selection didn't change
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return
}
if (!state.selection.empty) {
this.hide()
return
}
if (!state.selection.empty) {
this.hide()
return
}
const currentDom = view.domAtPos(state.selection.$anchor.pos)
const currentDom = view.domAtPos(state.selection.$anchor.pos)
const isActive = currentDom.node.innerHTML === '<br>'
&& currentDom.node.tagName === 'P'
&& currentDom.node.parentNode === view.dom
const isActive = currentDom.node.innerHTML === '<br>'
&& currentDom.node.tagName === 'P'
&& currentDom.node.parentNode === view.dom
if (!isActive) {
this.hide()
return
}
if (!isActive) {
this.hide()
return
}
const editorBoundings = this.options.element.offsetParent.getBoundingClientRect()
const cursorBoundings = view.coordsAtPos(state.selection.$anchor.pos)
const top = cursorBoundings.top - editorBoundings.top
const editorBoundings = this.options.element.offsetParent.getBoundingClientRect()
const cursorBoundings = view.coordsAtPos(state.selection.$anchor.pos)
const top = cursorBoundings.top - editorBoundings.top
this.isActive = true
this.top = top
this.isActive = true
this.top = top
this.sendUpdate()
}
this.sendUpdate()
}
sendUpdate() {
this.options.onUpdate({
isActive: this.isActive,
top: this.top,
})
}
sendUpdate() {
this.options.onUpdate({
isActive: this.isActive,
top: this.top,
})
}
hide(event) {
if (event && event.relatedTarget) {
return
}
hide(event) {
if (event && event.relatedTarget) {
return
}
this.isActive = false
this.sendUpdate()
}
this.isActive = false
this.sendUpdate()
}
destroy() {
this.editorView.dom.removeEventListener('blur', this.hide)
}
destroy() {
this.editorView.dom.removeEventListener('blur', this.hide)
}
}
export default function (options) {
return new Plugin({
view(editorView) {
return new Menu({ editorView, options })
},
})
return new Plugin({
view(editorView) {
return new Menu({ editorView, options })
},
})
}

View File

@@ -2,84 +2,84 @@ import { Plugin } from 'prosemirror-state'
class Menu {
constructor({ options, editorView }) {
this.options = {
...{
element: null,
onUpdate: () => false,
},
...options,
}
this.editorView = editorView
this.isActive = false
this.left = 0
this.bottom = 0
constructor({ options, editorView }) {
this.options = {
...{
element: null,
onUpdate: () => false,
},
...options,
}
this.editorView = editorView
this.isActive = false
this.left = 0
this.bottom = 0
this.editorView.dom.addEventListener('blur', this.hide.bind(this))
}
this.editorView.dom.addEventListener('blur', this.hide.bind(this))
}
update(view, lastState) {
const { state } = view
update(view, lastState) {
const { state } = view
// Don't do anything if the document/selection didn't change
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return
}
// Don't do anything if the document/selection didn't change
if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) {
return
}
// Hide the tooltip if the selection is empty
if (state.selection.empty) {
this.hide()
return
}
// Hide the tooltip if the selection is empty
if (state.selection.empty) {
this.hide()
return
}
// Otherwise, reposition it and update its content
const { from, to } = state.selection
// Otherwise, reposition it and update its content
const { from, to } = state.selection
// These are in screen coordinates
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
// These are in screen coordinates
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
// The box in which the tooltip is positioned, to use as base
const box = this.options.element.offsetParent.getBoundingClientRect()
// The box in which the tooltip is positioned, to use as base
const box = this.options.element.offsetParent.getBoundingClientRect()
// Find a center-ish x position from the selection endpoints (when
// crossing lines, end may be more to the left)
const left = Math.max((start.left + end.left) / 2, start.left + 3)
// Find a center-ish x position from the selection endpoints (when
// crossing lines, end may be more to the left)
const left = Math.max((start.left + end.left) / 2, start.left + 3)
this.isActive = true
this.left = parseInt(left - box.left, 10)
this.bottom = parseInt(box.bottom - start.top, 10)
this.isActive = true
this.left = parseInt(left - box.left, 10)
this.bottom = parseInt(box.bottom - start.top, 10)
this.sendUpdate()
}
this.sendUpdate()
}
sendUpdate() {
this.options.onUpdate({
isActive: this.isActive,
left: this.left,
bottom: this.bottom,
})
}
sendUpdate() {
this.options.onUpdate({
isActive: this.isActive,
left: this.left,
bottom: this.bottom,
})
}
hide(event) {
if (event && event.relatedTarget) {
return
}
hide(event) {
if (event && event.relatedTarget) {
return
}
this.isActive = false
this.sendUpdate()
}
this.isActive = false
this.sendUpdate()
}
destroy() {
this.editorView.dom.removeEventListener('blur', this.hide)
}
destroy() {
this.editorView.dom.removeEventListener('blur', this.hide)
}
}
export default function (options) {
return new Plugin({
view(editorView) {
return new Menu({ editorView, options })
},
})
return new Plugin({
view(editorView) {
return new Menu({ editorView, options })
},
})
}

View File

@@ -2,9 +2,9 @@ import { lift, selectParentNode } from 'prosemirror-commands'
import { undoInputRule } from 'prosemirror-inputrules'
const keymap = {
'Mod-BracketLeft': lift,
Backspace: undoInputRule,
Escape: selectParentNode,
'Mod-BracketLeft': lift,
Backspace: undoInputRule,
Escape: selectParentNode,
}
export default keymap

View File

@@ -1,34 +1,34 @@
export default class Extension {
constructor(options = {}) {
this.options = {
...this.defaultOptions,
...options,
}
}
constructor(options = {}) {
this.options = {
...this.defaultOptions,
...options,
}
}
get name() {
return null
}
get name() {
return null
}
get type() {
return 'extension'
}
get type() {
return 'extension'
}
get defaultOptions() {
return {}
}
get defaultOptions() {
return {}
}
get plugins() {
return []
}
get plugins() {
return []
}
inputRules() {
return []
}
inputRules() {
return []
}
keys() {
return {}
}
keys() {
return {}
}
}

View File

@@ -2,24 +2,24 @@ import Extension from './Extension'
export default class Mark extends Extension {
constructor(options = {}) {
super(options)
}
constructor(options = {}) {
super(options)
}
get type() {
return 'mark'
}
get type() {
return 'mark'
}
get view() {
return null
}
get view() {
return null
}
get schema() {
return null
}
get schema() {
return null
}
command() {
return () => {}
}
command() {
return () => {}
}
}

View File

@@ -2,24 +2,24 @@ import Extension from './Extension'
export default class Node extends Extension {
constructor(options = {}) {
super(options)
}
constructor(options = {}) {
super(options)
}
get type() {
return 'node'
}
get type() {
return 'node'
}
get view() {
return null
}
get view() {
return null
}
get schema() {
return null
}
get schema() {
return null
}
command() {
return () => {}
}
command() {
return () => {}
}
}